mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a85400d570 | ||
|
|
8ce6668c78 | ||
|
|
2d8121a708 | ||
|
|
172c039c79 | ||
|
|
4ab1fd9294 | ||
|
|
50b3fde075 | ||
|
|
1a3fc6f94d | ||
|
|
26ed42230a | ||
|
|
6f4defdc1b | ||
|
|
f798aed342 | ||
|
|
27e098c244 | ||
|
|
37948be0d3 | ||
|
|
cc7bbb77c4 | ||
|
|
96da0b7892 | ||
|
|
72993312c7 | ||
|
|
17b4bad2e4 | ||
|
|
fbad4456d7 | ||
|
|
deec626feb | ||
|
|
88c6641485 | ||
|
|
f4eedda658 | ||
|
|
d2b80561f8 | ||
|
|
3bda88a075 | ||
|
|
86465e0076 | ||
|
|
d947dad488 | ||
|
|
23ef434fe1 | ||
|
|
5e6d92c5be | ||
|
|
4ba098e0b6 | ||
|
|
2d1a9a0452 | ||
|
|
6fbee3e7c6 | ||
|
|
46775c3662 | ||
|
|
1feb5bfda1 | ||
|
|
3ec9ed3b2a | ||
|
|
75a536d5ab | ||
|
|
f3cded7e5d | ||
|
|
d7c9c4bf76 |
@@ -203,10 +203,6 @@ module.exports = {
|
|||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/self-closing-comp.md
|
||||||
'react/self-closing-comp': 'warn',
|
'react/self-closing-comp': 'warn',
|
||||||
|
|
||||||
// Enforce spaces before the closing bracket of self-closing JSX elements
|
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-space-before-closing.md
|
|
||||||
'react/jsx-space-before-closing': ['warn', 'always'],
|
|
||||||
|
|
||||||
// Enforce component methods order
|
// Enforce component methods order
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/sort-comp.md
|
||||||
'react/sort-comp': 'off',
|
'react/sort-comp': 'off',
|
||||||
@@ -237,7 +233,7 @@ module.exports = {
|
|||||||
|
|
||||||
// only .jsx files may have JSX
|
// only .jsx files may have JSX
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-filename-extension.md
|
||||||
'react/jsx-filename-extension': ['error', { extensions: ['.js'] }],
|
'react/jsx-filename-extension': ['error', { extensions: ['.jsx'] }],
|
||||||
|
|
||||||
// prevent accidental JS comments from being injected into JSX as text
|
// prevent accidental JS comments from being injected into JSX as text
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-comment-textnodes.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-no-comment-textnodes.md
|
||||||
@@ -282,15 +278,5 @@ module.exports = {
|
|||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/no-children-prop.md
|
||||||
'react/no-children-prop': 'warn',
|
'react/no-children-prop': 'warn',
|
||||||
|
|
||||||
// Validate whitespace in and around the JSX opening and closing brackets
|
|
||||||
// https://github.com/yannickcr/eslint-plugin-react/blob/master/docs/rules/jsx-tag-spacing.md
|
|
||||||
'react/jsx-tag-spacing': [
|
|
||||||
'warn',
|
|
||||||
{
|
|
||||||
closingSlash: 'never',
|
|
||||||
beforeSelfClosing: 'always',
|
|
||||||
afterOpening: 'never',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
- name: Setup node
|
- name: Setup node
|
||||||
uses: actions/setup-node@v2.5.1
|
uses: actions/setup-node@v2.5.1
|
||||||
with:
|
with:
|
||||||
node-version: 16
|
node-version: 18
|
||||||
cache: 'yarn'
|
cache: 'yarn'
|
||||||
- run: yarn install
|
- run: yarn install
|
||||||
- run: yarn run test
|
- run: yarn run test
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1.3
|
# syntax=docker/dockerfile:1.3
|
||||||
FROM node:16-alpine AS builder
|
FROM node:18-alpine AS builder
|
||||||
COPY --chown=1000:1000 . /fredy
|
COPY --chown=1000:1000 . /fredy
|
||||||
WORKDIR /fredy
|
WORKDIR /fredy
|
||||||
USER 1000
|
USER 1000
|
||||||
@@ -10,6 +10,7 @@ FROM node:16-alpine
|
|||||||
COPY --from=builder --chown=1000:1000 /fredy /fredy
|
COPY --from=builder --chown=1000:1000 /fredy /fredy
|
||||||
RUN mkdir /db /conf && \
|
RUN mkdir /db /conf && \
|
||||||
chown 1000:1000 /db /conf && \
|
chown 1000:1000 /db /conf && \
|
||||||
|
chmod 777 -R /db/ && \
|
||||||
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
ln -s /db /fredy/db && ln -s /conf /fredy/conf
|
||||||
EXPOSE 9998
|
EXPOSE 9998
|
||||||
USER 1000
|
USER 1000
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2021 Christian Kellner
|
Copyright (c) 2024 Christian Kellner
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -17,7 +17,7 @@ _Fredy_ is supported by JetBrains under Open Source Support Program
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
- Make sure to use Node.js 16 or above
|
- Make sure to use Node.js 18 or above
|
||||||
- Run the following commands:
|
- Run the following commands:
|
||||||
```ssh
|
```ssh
|
||||||
yarn (or npm install)
|
yarn (or npm install)
|
||||||
@@ -27,14 +27,11 @@ yarn run start
|
|||||||
_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.
|
_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">
|
<p align="center">
|
||||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot__1.png" width="30%">
|
<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/screenshot2.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/screenshot3.png">
|
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Understanding the fundamentals
|
## Understanding the fundamentals
|
||||||
@@ -81,10 +78,10 @@ yarn run test
|
|||||||
# Architecture
|
# Architecture
|
||||||

|

|
||||||
|
|
||||||
### Immoscout
|
### Immoscout / Immonet
|
||||||
I have added **experimental** support for Immoscout. Immoscout is somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
I have added **experimental** support for Immoscout and Immonet. They both are somewhat special, because they have decided to secure their service from bots using Re-Capture. Finding a way around this is barely possible. For _Fredy_ to be able to bypass this check, I'm using a service called [ScrapingAnt](https://scrapingant.com/). The trick is to use a headless browser, rotating proxies and (once successfully validated) to re-send the cookies each time.
|
||||||
|
|
||||||
To be able to use Immoscout, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
To be able to use Immoscout / Immonet, you need to create an account at ScrapingAnt. Configure the API key in the "General Settings" tab (visible when logged in as administrator).
|
||||||
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
The rest will be handled by _Fredy_. Keep in mind, the support is experimental. There might be bugs and you might not always pass the re-capture check, but most of the time it works rather well :)
|
||||||
|
|
||||||
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
If you need more than the 1000 API calls allowed per month, I'd suggest opting for a paid account... ScrapingAnt loves OpenSource, therefore they have decided to give all _Fredy_ users a 10% discount by using the code **FREDY10** (Disclaimer: I do not earn any money for recommending their service).
|
||||||
|
|||||||
BIN
doc/screenshot1.png
Normal file
BIN
doc/screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 380 KiB |
BIN
doc/screenshot_2.png
Normal file
BIN
doc/screenshot_2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 323 KiB |
BIN
doc/screenshot_3.png
Normal file
BIN
doc/screenshot_3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 202 KiB |
@@ -4,12 +4,11 @@
|
|||||||
<meta charset="UTF-8"
|
<meta charset="UTF-8"
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui@2/dist/semantic.min.css">
|
|
||||||
<meta name="google" content="notranslate">
|
<meta name="google" content="notranslate">
|
||||||
|
|
||||||
<title>Fredy</title>
|
<title>Fredy</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body theme-mode="dark">
|
||||||
|
|
||||||
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -45,15 +45,15 @@ class FredyRuntime {
|
|||||||
_getListings(url) {
|
_getListings(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = this._providerId;
|
const id = this._providerId;
|
||||||
if (scrapingAnt.isImmoscout(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
if (scrapingAnt.needScrapingAnt(id) && !scrapingAnt.isScrapingAntApiKeySet()) {
|
||||||
const error = 'Immoscout can only be used with if you have set an apikey for scrapingAnt.';
|
const error = 'Immoscout or Immonet can only be used with if you have set an apikey for scrapingAnt.';
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
console.log(error);
|
console.log(error);
|
||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
reject(error);
|
reject(error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const u = scrapingAnt.isImmoscout(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
const u = scrapingAnt.needScrapingAnt(id) ? scrapingAnt.transformUrlForScrapingAnt(url, id) : url;
|
||||||
try {
|
try {
|
||||||
if (this._providerConfig.paginate != null) {
|
if (this._providerConfig.paginate != null) {
|
||||||
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
xray(u, this._providerConfig.crawlContainer, [this._providerConfig.crawlFields])
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import files from 'serve-static';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { getDirName } from '../utils.js';
|
import { getDirName } from '../utils.js';
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const staticService = files(path.join(getDirName(), '../../ui/public'));
|
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||||
const PORT = config.port || 9998;
|
const PORT = config.port || 9998;
|
||||||
|
|
||||||
service.use(bodyParser.json());
|
service.use(bodyParser.json());
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ jobRouter.get('/processingTimes', async (req, res) => {
|
|||||||
let scrapingAntData = null;
|
let scrapingAntData = null;
|
||||||
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
|
if (config.scrapingAnt.apiKey != null && config.scrapingAnt.apiKey.length > 0) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`https://api.scrapingant.com/v1/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
const response = await fetch(`https://api.scrapingant.com/v2/usage?x-api-key=${config.scrapingAnt.apiKey}`);
|
||||||
scrapingAntData = await response.json();
|
scrapingAntData = await response.json();
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error('Could not query plan data from scraping ant.', Exception);
|
console.error('Could not query plan data from scraping ant.', Exception);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTe
|
|||||||
const emailTemplate = Handlebars.compile(template);
|
const emailTemplate = Handlebars.compile(template);
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||||
(adapter) => adapter.id === 'mailJet'
|
(adapter) => adapter.id === config.id,
|
||||||
).fields;
|
).fields;
|
||||||
const to = receiver
|
const to = receiver
|
||||||
.trim()
|
.trim()
|
||||||
@@ -18,7 +18,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
Email: r.trim(),
|
Email: r.trim(),
|
||||||
}));
|
}));
|
||||||
return mailjet
|
return mailjet
|
||||||
.connect(apiPublicKey, apiPrivateKey)
|
.apiConnect(apiPublicKey, apiPrivateKey)
|
||||||
.post('send', { version: 'v3.1' })
|
.post('send', { version: 'v3.1' })
|
||||||
.request({
|
.request({
|
||||||
Messages: [
|
Messages: [
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { markdown2Html } from '../../services/markdown.js';
|
|||||||
import { getJob } from '../../services/storage/jobStorage.js';
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === 'mattermost').fields;
|
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
||||||
message += newListings.map(
|
message += newListings.map(
|
||||||
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n'
|
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
|
||||||
);
|
);
|
||||||
return fetch(webhook, {
|
return fetch(webhook, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
51
lib/notification/adapter/ntfy.js
Normal file
51
lib/notification/adapter/ntfy.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
|
import { getJob } from '../../services/storage/jobStorage.js';
|
||||||
|
import fetch from 'node-fetch';
|
||||||
|
|
||||||
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
|
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
|
const job = getJob(jobKey);
|
||||||
|
const jobName = job == null ? jobKey : job.name;
|
||||||
|
const promises = newListings.map((newListing) => {
|
||||||
|
const message = `Address: ${newListing.address} Size: ${newListing.size.replace(/2m/g, '$m^2$')} Price: ${
|
||||||
|
newListing.price
|
||||||
|
}`;
|
||||||
|
return fetch(server, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
topic: topic,
|
||||||
|
message: message,
|
||||||
|
title: newListing.title,
|
||||||
|
tags: [serviceName, jobName],
|
||||||
|
priority: parseInt(priority),
|
||||||
|
click: newListing.link,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
};
|
||||||
|
export const config = {
|
||||||
|
id: 'ntfy',
|
||||||
|
name: 'ntfy',
|
||||||
|
readme: markdown2Html('lib/notification/adapter/ntfy.md'),
|
||||||
|
description: 'Fredy will send new listings to your ntfy.',
|
||||||
|
fields: {
|
||||||
|
priority: {
|
||||||
|
type: 'number',
|
||||||
|
label: 'Priority',
|
||||||
|
description: 'The priority of the send notification.',
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'Server-URL',
|
||||||
|
description: 'The server url to the send the notification to.',
|
||||||
|
},
|
||||||
|
topic: {
|
||||||
|
type: 'text',
|
||||||
|
label: 'topic',
|
||||||
|
description:
|
||||||
|
'The topic where fredy should send notifications to. The topic is a secret, only known to you, make sure it is something not generic.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
5
lib/notification/adapter/ntfy.md
Normal file
5
lib/notification/adapter/ntfy.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
### ntfy Adapter
|
||||||
|
|
||||||
|
For ntfy, you need to create a topic on your preferred ntfy instance. This is pretty easy. Please visit the steps in the [docs](https://docs.ntfy.sh/publish/) and follow the instructions.
|
||||||
|
|
||||||
|
As a result, you get the URL for configuration in fredy. In addition, the priority must be defined.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import sgMail from '@sendgrid/mail';
|
import sgMail from '@sendgrid/mail';
|
||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === 'sendGrid').fields;
|
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
sgMail.setApiKey(apiKey);
|
sgMail.setApiKey(apiKey);
|
||||||
const msg = {
|
const msg = {
|
||||||
templateId,
|
templateId,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Slack from 'slack';
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
import { markdown2Html } from '../../services/markdown.js';
|
||||||
const msg = Slack.chat.postMessage;
|
const msg = Slack.chat.postMessage;
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, channel } = notificationConfig.find((adapter) => adapter.id === 'slack').fields;
|
const { token, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
return newListings.map((payload) =>
|
return newListings.map((payload) =>
|
||||||
msg({
|
msg({
|
||||||
token,
|
token,
|
||||||
@@ -35,7 +35,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
ts: new Date().getTime() / 1000,
|
ts: new Date().getTime() / 1000,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { markdown2Html } from '../../services/markdown.js';
|
|
||||||
import Database from 'better-sqlite3';
|
|
||||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
|
||||||
const db = new Database('db/listings.db');
|
|
||||||
const fields = ['serviceName', 'jobKey', 'id', 'size', 'rooms', 'price', 'address', 'title', 'link', 'description'];
|
|
||||||
db.prepare(`CREATE TABLE IF NOT EXISTS listing (${fields.join(' TEXT, ')} TEXT);`).run();
|
|
||||||
const insert = db.prepare(`INSERT INTO listing (${fields.join(', ')}) VALUES (@${fields.join(', @')})`);
|
|
||||||
newListings.map((listing) => {
|
|
||||||
let insertListing = {};
|
|
||||||
fields.map((field) => {
|
|
||||||
insertListing[field] = listing[field];
|
|
||||||
});
|
|
||||||
insertListing.serviceName = serviceName;
|
|
||||||
insertListing.jobKey = jobKey;
|
|
||||||
insert.run(insertListing);
|
|
||||||
});
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
|
||||||
export const config = {
|
|
||||||
id: 'sqlite',
|
|
||||||
name: 'Sqlite',
|
|
||||||
description: 'This adapter stores listings in a local sqlite3 database.',
|
|
||||||
config: {},
|
|
||||||
readme: markdown2Html('lib/notification/adapter/sqlite.md'),
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
### Sqlite Adapter
|
|
||||||
|
|
||||||
This adapter stores search results in an sqlite database in db/listings.db
|
|
||||||
@@ -19,7 +19,7 @@ function shorten(str, len = 30) {
|
|||||||
return str.length > len ? str.substring(0, len) + '...' : str;
|
return str.length > len ? str.substring(0, len) + '...' : str;
|
||||||
}
|
}
|
||||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||||
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === 'telegram').fields;
|
const { token, chatId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||||
const job = getJob(jobKey);
|
const job = getJob(jobKey);
|
||||||
const jobName = job == null ? jobKey : job.name;
|
const jobName = job == null ? jobKey : job.name;
|
||||||
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
//we have to split messages into chunk, because otherwise messages are going to become too big and will fail
|
||||||
@@ -30,7 +30,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
|||||||
(o) =>
|
(o) =>
|
||||||
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
`<a href='${o.link}'><b>${shorten(o.title.replace(/\*/g, ''), 45).trim()}</b></a>\n` +
|
||||||
[o.address, o.price, o.size].join(' | ') +
|
[o.address, o.price, o.size].join(' | ') +
|
||||||
'\n\n'
|
'\n\n',
|
||||||
);
|
);
|
||||||
/**
|
/**
|
||||||
* This is to not break the rate limit. It is to only send 1 message per second
|
* This is to not break the rate limit. It is to only send 1 message per second
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import utils from '../utils.js';
|
import utils from '../utils.js';
|
||||||
let appliedBlackList = [];
|
let appliedBlackList = [];
|
||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const id = parseInt(o.id.substring(o.id.indexOf('_') + 1, o.id.length));
|
const id = o.id.substring(o.id.lastIndexOf('/') + 1, o.id.length);
|
||||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||||
const price = o.price.replace('Kaufpreis ', '');
|
const price = o.price.replace('Kaufpreis ', '');
|
||||||
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
||||||
const title = o.title || 'No title available';
|
const title = o.title || 'No title available';
|
||||||
//normally we would just read the link from the source, but immonet decided to trick user by adding a click listener instead of
|
const link = o.id;
|
||||||
//a href to do some weird reporting. (Very user friendly for handicaped ppl... not)
|
|
||||||
const link = `https://www.immonet.de/angebot/${id}`;
|
|
||||||
return Object.assign(o, { id, address, price, size, title, link });
|
return Object.assign(o, { id, address, price, size, title, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
@@ -18,14 +16,14 @@ function applyBlacklist(o) {
|
|||||||
}
|
}
|
||||||
const config = {
|
const config = {
|
||||||
url: null,
|
url: null,
|
||||||
crawlContainer: '#result-list-stage .item',
|
crawlContainer: '.content-wrapper-tiles .ng-star-inserted',
|
||||||
sortByDateParam: 'sortby=19',
|
sortByDateParam: 'sortby=19',
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '@id',
|
id: '.card a@href',
|
||||||
price: 'div[id*="selPrice_"] | trim',
|
title: '.card h3 |trim',
|
||||||
size: 'div[id*="selArea_"] | trim',
|
price: '.card .has-font-300 .is-bold | trim',
|
||||||
title: '.item a img@title',
|
size: '.card .has-font-300 .ml-100 | trim',
|
||||||
address: '.item .box-25 .ellipsis .text-100 | removeNewline | trim',
|
address: '.card span:nth-child(2) | trim',
|
||||||
},
|
},
|
||||||
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
paginate: '#idResultList .margin-bottom-6.margin-bottom-sm-12 .panel a.pull-right@href',
|
||||||
normalize: normalize,
|
normalize: normalize,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ function nullOrEmpty(val) {
|
|||||||
function normalize(o) {
|
function normalize(o) {
|
||||||
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
const title = nullOrEmpty(o.title) ? 'NO TITLE FOUND' : o.title.replace('NEU', '');
|
||||||
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
const address = nullOrEmpty(o.address) ? 'NO ADDRESS FOUND' : (o.address || '').replace(/\(.*\),.*$/, '').trim();
|
||||||
const link = `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
const link = nullOrEmpty(o.address) ? 'NO LINK' : `https://www.immobilienscout24.de${o.link.substring(o.link.indexOf('/expose'))}`;
|
||||||
return Object.assign(o, { title, address, link });
|
return Object.assign(o, { title, address, link });
|
||||||
}
|
}
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
@@ -20,7 +20,7 @@ const config = {
|
|||||||
id: '.result-list-entry@data-obid | int',
|
id: '.result-list-entry@data-obid | int',
|
||||||
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',
|
price: '.result-list-entry .result-list-entry__criteria .grid-item:first-child dd | removeNewline | trim',
|
||||||
size: '.result-list-entry .result-list-entry__criteria .grid-item:nth-child(2) dd | removeNewline | trim',
|
size: '.result-list-entry .result-list-entry__criteria .grid-item:nth-child(2) dd | removeNewline | trim',
|
||||||
title: '.result-list-entry .result-list-entry__brand-title-container h5 | removeNewline | trim',
|
title: '.result-list-entry .result-list-entry__brand-title-container h2 | removeNewline | trim',
|
||||||
link: '.result-list-entry .result-list-entry__brand-title-container@href',
|
link: '.result-list-entry .result-list-entry__brand-title-container@href',
|
||||||
address: '.result-list-entry .result-list-entry__map-link',
|
address: '.result-list-entry .result-list-entry__map-link',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const config = {
|
|||||||
sortByDateParam: null,
|
sortByDateParam: null,
|
||||||
crawlFields: {
|
crawlFields: {
|
||||||
id: '.aditem@data-adid | int',
|
id: '.aditem@data-adid | int',
|
||||||
price: '.aditem-main--middle--price | removeNewline | trim',
|
price: '.aditem-main--middle--price-shipping--price | removeNewline | trim',
|
||||||
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
size: '.aditem-main .text-module-end span:nth-child(2) | removeNewline | trim',
|
||||||
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
title: '.aditem-main .text-module-begin a | removeNewline | trim',
|
||||||
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
link: '.aditem-main .text-module-begin a@href | removeNewline | trim',
|
||||||
@@ -32,7 +32,7 @@ const config = {
|
|||||||
};
|
};
|
||||||
export const metaInformation = {
|
export const metaInformation = {
|
||||||
name: 'Ebay Kleinanzeigen',
|
name: 'Ebay Kleinanzeigen',
|
||||||
baseUrl: 'https://www.ebay-kleinanzeigen.de/',
|
baseUrl: 'https://www.kleinanzeigen.de/',
|
||||||
id: 'kleinanzeigen',
|
id: 'kleinanzeigen',
|
||||||
};
|
};
|
||||||
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
export const init = (sourceConfig, blacklist, blacklistedDistricts) => {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { config } from '../utils.js';
|
import { config } from '../utils.js';
|
||||||
import { makeUrlResidential } from './scrapingAnt.js';
|
import { makeUrlResidential } from './scrapingAnt.js';
|
||||||
|
import https from 'https';
|
||||||
//if ScrapingAnt got blocked, this http status is returned
|
//if ScrapingAnt got blocked, this http status is returned
|
||||||
const BLOCKED_HTTP_STATUS = 423;
|
const BLOCKED_HTTP_STATUS = 423;
|
||||||
const NOT_FOUND_HTTP_STATUS = 404;
|
const NOT_FOUND_HTTP_STATUS = 404;
|
||||||
const MAX_RETRIES_SCRAPING_ANT = 10;
|
const MAX_RETRIES_SCRAPING_ANT = 10;
|
||||||
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
|
const EXPECTED_STATUS_CODES = [BLOCKED_HTTP_STATUS, NOT_FOUND_HTTP_STATUS];
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
});
|
||||||
|
|
||||||
function makeDriver(headers = {}) {
|
function makeDriver(headers = {}) {
|
||||||
let cookies = '';
|
let cookies = '';
|
||||||
async function scrapingAntDriver(context, callback, retryCounter = 0) {
|
async function scrapingAntDriver(context, callback, retryCounter = 0) {
|
||||||
@@ -41,9 +46,10 @@ function makeDriver(headers = {}) {
|
|||||||
/* eslint-enable no-console */
|
/* eslint-enable no-console */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
|
* The regular request driver is taking care of everyting, that doesn't need to be scraped by ScrapingAnt (which is
|
||||||
* everything != Immoscout as of writing this)
|
* everything != Immoscout & Immonet as of writing this)
|
||||||
*/
|
*/
|
||||||
return async function driver(context, callback) {
|
return async function driver(context, callback) {
|
||||||
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
|
if (context.url.toLowerCase().indexOf('scrapingant') !== -1) {
|
||||||
@@ -55,6 +61,7 @@ function makeDriver(headers = {}) {
|
|||||||
...headers,
|
...headers,
|
||||||
Cookie: cookies,
|
Cookie: cookies,
|
||||||
},
|
},
|
||||||
|
agent,
|
||||||
});
|
});
|
||||||
const result = await response.text();
|
const result = await response.text();
|
||||||
callback(null, result);
|
callback(null, result);
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { metaInformation } from '../provider/immoscout.js';
|
import { metaInformation as immoScoutInfo } from '../provider/immoscout.js';
|
||||||
|
import { metaInformation as immoNetInfo } from '../provider/immonet.js';
|
||||||
import { config } from '../utils.js';
|
import { config } from '../utils.js';
|
||||||
const isImmoscout = (id) => {
|
|
||||||
return id.toLowerCase() === metaInformation.id;
|
const additionalImmonetUrlParams = `&wait_for_selector=.content-wrapper-tiles&js_snippet=${Buffer.from(
|
||||||
|
'window.scrollTo(0,document.body.scrollHeight);'
|
||||||
|
).toString('base64')}`;
|
||||||
|
|
||||||
|
const needScrapingAnt = (id) => {
|
||||||
|
return id.toLowerCase() === immoScoutInfo.id || id.toLowerCase() === immoNetInfo.id;
|
||||||
};
|
};
|
||||||
export const transformUrlForScrapingAnt = (url, id) => {
|
export const transformUrlForScrapingAnt = (url, id) => {
|
||||||
if (isImmoscout(id)) {
|
let urlParams = '';
|
||||||
//only do calls to scrapingAnt when dealing with Immoscout
|
if (needScrapingAnt(id)) {
|
||||||
url = `https://api.scrapingant.com/v1/general?url=${encodeURIComponent(url)}&proxy_type=datacenter`;
|
if (id.toLowerCase() === immoNetInfo.id) {
|
||||||
|
urlParams = additionalImmonetUrlParams;
|
||||||
|
}
|
||||||
|
//only do calls to scrapingAnt when dealing with Immoscout/Immonet
|
||||||
|
url = `https://api.scrapingant.com/v2/general?url=${encodeURIComponent(url)}&proxy_type=datacenter${urlParams}`;
|
||||||
}
|
}
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
@@ -16,4 +26,4 @@ export const isScrapingAntApiKeySet = () => {
|
|||||||
export const makeUrlResidential = (url) => {
|
export const makeUrlResidential = (url) => {
|
||||||
return url.replace('datacenter', 'residential');
|
return url.replace('datacenter', 'residential');
|
||||||
};
|
};
|
||||||
export { isImmoscout };
|
export { needScrapingAnt };
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { LowSync } from 'lowdb';
|
import { LowSync } from 'lowdb';
|
||||||
export default class LowdashAdapter extends LowSync {
|
export default class LowdashAdapter extends LowSync {
|
||||||
constructor(adapter) {
|
constructor(adapter, defaultData = {}) {
|
||||||
super(adapter);
|
super(adapter, defaultData);
|
||||||
this.chain = lodash.chain(this).get('data');
|
this.chain = lodash.chain(this).get('data');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
|||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, { jobs: [] });
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
|
|
||||||
db.data ||= { jobs: [] };
|
|
||||||
|
|
||||||
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
export const upsertJob = ({ jobId, name, blacklist = [], enabled = true, provider, notificationAdapter, userId }) => {
|
||||||
const currentJob =
|
const currentJob =
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ import LowdashAdapter from './LowDashAdapter.js';
|
|||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
const file = path.join(getDirName(), '../', 'db/jobListingData.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, {});
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
|
|
||||||
db.data ||= {};
|
|
||||||
|
|
||||||
const buildKey = (jobKey, providerId, endpoint) => {
|
const buildKey = (jobKey, providerId, endpoint) => {
|
||||||
let key = `${jobKey}`;
|
let key = `${jobKey}`;
|
||||||
if (jobKey == null && endpoint == null) {
|
if (jobKey == null && endpoint == null) {
|
||||||
|
|||||||
@@ -6,24 +6,24 @@ import * as jobStorage from './jobStorage.js';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import LowdashAdapter from './LowDashAdapter.js';
|
import LowdashAdapter from './LowDashAdapter.js';
|
||||||
|
|
||||||
|
const defaultData = {
|
||||||
|
user: [
|
||||||
|
//you probably want to change the default password ;)
|
||||||
|
{
|
||||||
|
id: nanoid(),
|
||||||
|
lastLogin: Date.now(),
|
||||||
|
username: 'admin',
|
||||||
|
password: hasher.hash('admin'),
|
||||||
|
isAdmin: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
const file = path.join(getDirName(), '../', 'db/users.json');
|
const file = path.join(getDirName(), '../', 'db/users.json');
|
||||||
const adapter = new JSONFileSync(file);
|
const adapter = new JSONFileSync(file);
|
||||||
const db = new LowdashAdapter(adapter);
|
const db = new LowdashAdapter(adapter, defaultData);
|
||||||
|
|
||||||
db.read();
|
db.read();
|
||||||
db.data ||= {
|
|
||||||
user: [
|
|
||||||
//you probably want to change the default password ;)
|
|
||||||
{
|
|
||||||
id: nanoid(),
|
|
||||||
lastLogin: Date.now(),
|
|
||||||
username: 'admin',
|
|
||||||
password: hasher.hash('admin'),
|
|
||||||
isAdmin: true,
|
|
||||||
isDemo: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUsers = (withPassword) => {
|
export const getUsers = (withPassword) => {
|
||||||
const jobs = jobStorage.getJobs();
|
const jobs = jobStorage.getJobs();
|
||||||
|
|||||||
67
package.json
67
package.json
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "7.0.0",
|
"version": "8.0.2",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
"dev": "yarn && rm -rf ./ui/public/* && vite",
|
||||||
"ui": "rm -rf ./ui/public/* && vite",
|
"ui": "rm -rf ./ui/public/* && vite",
|
||||||
"prod": "yarn && vite build --emptyOutDir",
|
"prod": "yarn && vite build --emptyOutDir",
|
||||||
"format": "prettier --write lib/**/*.js ui/src/**/*.js test/**/*.js *.js --single-quote --print-width 120",
|
"format": "prettier --write lib/**/*.js ui/src/**/*.jsx test/**/*.js *.js --single-quote --print-width 120",
|
||||||
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
"test": "mocha --loader=esmock --timeout 3000000 test/**/*.test.js",
|
||||||
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js"
|
"lint": "eslint ./index.js ./lib/**/*.js ./test/**/*.js ./ui/src/**/*.jsx"
|
||||||
},
|
},
|
||||||
"husky": {
|
"husky": {
|
||||||
"hooks": {
|
"hooks": {
|
||||||
@@ -55,55 +55,54 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@douyinfe/semi-ui": "2.52.0",
|
||||||
"@rematch/core": "2.2.0",
|
"@rematch/core": "2.2.0",
|
||||||
"@rematch/loading": "2.1.2",
|
"@rematch/loading": "2.1.2",
|
||||||
"@sendgrid/mail": "7.7.0",
|
"@sendgrid/mail": "8.1.0",
|
||||||
"@vitejs/plugin-react": "3.1.0",
|
"@vitejs/plugin-react": "4.2.1",
|
||||||
"better-sqlite3": "8.2.0",
|
"better-sqlite3": "8.6.0",
|
||||||
"body-parser": "1.20.2",
|
"body-parser": "1.20.2",
|
||||||
"cookie-session": "2.0.0",
|
"cookie-session": "2.1.0",
|
||||||
"handlebars": "4.7.7",
|
"handlebars": "4.7.8",
|
||||||
"highcharts": "10.3.3",
|
"highcharts": "11.3.0",
|
||||||
"highcharts-react-official": "3.2.0",
|
"highcharts-react-official": "3.2.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "4.17.21",
|
||||||
"lowdb": "5.1.0",
|
"lowdb": "6.0.1",
|
||||||
"markdown": "^0.5.0",
|
"markdown": "^0.5.0",
|
||||||
"nanoid": "4.0.1",
|
"nanoid": "5.0.5",
|
||||||
"node-fetch": "3.3.1",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.2",
|
"node-mailjet": "6.0.5",
|
||||||
"query-string": "8.1.0",
|
"query-string": "8.2.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-redux": "8.0.5",
|
"react-redux": "9.1.0",
|
||||||
"react-router": "5.2.1",
|
"react-router": "5.2.1",
|
||||||
"react-router-dom": "5.3.0",
|
"react-router-dom": "5.3.0",
|
||||||
"react-switch": "7.0.0",
|
"redux": "5.0.1",
|
||||||
"redux": "4.2.1",
|
"redux-thunk": "3.1.0",
|
||||||
"redux-thunk": "2.4.2",
|
|
||||||
"restana": "4.9.7",
|
"restana": "4.9.7",
|
||||||
"semantic-ui-react": "2.1.4",
|
|
||||||
"serve-static": "1.15.0",
|
"serve-static": "1.15.0",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"string-similarity": "^4.0.4",
|
"string-similarity": "^4.0.4",
|
||||||
"vite": "4.1.4",
|
"vite": "5.0.12",
|
||||||
"x-ray": "2.3.4"
|
"x-ray": "2.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esmock": "2.1.0",
|
"@babel/core": "7.23.9",
|
||||||
"@babel/core": "7.21.0",
|
"@babel/eslint-parser": "7.23.10",
|
||||||
"@babel/eslint-parser": "7.19.1",
|
"@babel/preset-env": "7.23.9",
|
||||||
"@babel/preset-env": "7.20.2",
|
"@babel/preset-react": "7.23.3",
|
||||||
"@babel/preset-react": "7.18.6",
|
"chai": "5.0.3",
|
||||||
"chai": "4.3.7",
|
"eslint": "8.56.0",
|
||||||
"eslint": "8.36.0",
|
"eslint-config-prettier": "8.8.0",
|
||||||
"eslint-config-prettier": "8.7.0",
|
"eslint-plugin-react": "7.33.2",
|
||||||
"eslint-plugin-react": "7.32.2",
|
"esmock": "2.6.3",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "4.3.8",
|
"husky": "4.3.8",
|
||||||
"less": "4.1.3",
|
"less": "4.2.0",
|
||||||
"lint-staged": "13.2.0",
|
"lint-staged": "13.2.2",
|
||||||
"mocha": "10.2.0",
|
"mocha": "10.2.0",
|
||||||
"prettier": "2.8.4",
|
"prettier": "3.2.5",
|
||||||
"redux-logger": "3.0.6"
|
"redux-logger": "3.0.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
import * as provider from '../../lib/provider/einsAImmobilien.js';
|
||||||
|
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
describe('#einsAImmobilien testsuite()', () => {
|
describe('#einsAImmobilien testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { providerConfig, mockFredy } from '../utils.js';
|
import { providerConfig, mockFredy } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immobilienDe.js';
|
import * as provider from '../../lib/provider/immobilienDe.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immobilien.de testsuite()', () => {
|
describe('#immobilien.de testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immonet.js';
|
import * as provider from '../../lib/provider/immonet.js';
|
||||||
const expect = chai.expect;
|
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||||
|
|
||||||
describe('#immonet testsuite()', () => {
|
describe('#immonet testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -12,6 +13,13 @@ describe('#immonet testsuite()', () => {
|
|||||||
it('should test immonet provider', async () => {
|
it('should test immonet provider', async () => {
|
||||||
const Fredy = await mockFredy();
|
const Fredy = await mockFredy();
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
|
if (!scrapingAnt.isScrapingAntApiKeySet()) {
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
console.info('Skipping Immonet test as ScrapingAnt Api Key is not set.');
|
||||||
|
/* eslint-enable no-console */
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
const fredy = new Fredy(provider.config, null, provider.metaInformation.id, 'immonet', similarityCache);
|
||||||
fredy.execute().then((listing) => {
|
fredy.execute().then((listing) => {
|
||||||
expect(listing).to.be.a('array');
|
expect(listing).to.be.a('array');
|
||||||
@@ -20,17 +28,17 @@ describe('#immonet testsuite()', () => {
|
|||||||
expect(notificationObj.serviceName).to.equal('immonet');
|
expect(notificationObj.serviceName).to.equal('immonet');
|
||||||
notificationObj.payload.forEach((notify) => {
|
notificationObj.payload.forEach((notify) => {
|
||||||
/** check the actual structure **/
|
/** check the actual structure **/
|
||||||
expect(notify.id).to.be.a('number');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
expect(notify.size).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
|
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.price).that.does.include('€');
|
expect(notify.price).that.does.include('€');
|
||||||
expect(notify.size).that.does.include('m²');
|
expect(notify.size).that.does.include('m²');
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://www.immonet.de');
|
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immoscout.js';
|
import * as provider from '../../lib/provider/immoscout.js';
|
||||||
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
import * as scrapingAnt from '../../lib/services/scrapingAnt.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immoscout testsuite()', () => {
|
describe('#immoscout testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immoswp.js';
|
import * as provider from '../../lib/provider/immoswp.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immoswp testsuite()', () => {
|
describe('#immoswp testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/immowelt.js';
|
import * as provider from '../../lib/provider/immowelt.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#immowelt testsuite()', () => {
|
describe('#immowelt testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
import * as provider from '../../lib/provider/kleinanzeigen.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#kleinanzeigen testsuite()', () => {
|
describe('#kleinanzeigen testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -26,7 +26,7 @@ describe('#kleinanzeigen testsuite()', () => {
|
|||||||
expect(notify.address).to.be.a('string');
|
expect(notify.address).to.be.a('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.title).to.be.not.empty;
|
expect(notify.title).to.be.not.empty;
|
||||||
expect(notify.link).that.does.include('https://www.ebay-kleinanzeigen.de');
|
expect(notify.link).that.does.include('https://www.kleinanzeigen.de');
|
||||||
expect(notify.address).to.be.not.empty;
|
expect(notify.address).to.be.not.empty;
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/neubauKompass.js';
|
import * as provider from '../../lib/provider/neubauKompass.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#neubauKompass testsuite()', () => {
|
describe('#neubauKompass testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immonet": {
|
"immonet": {
|
||||||
"url": "https://www.immonet.de/immobiliensuche/sel.do?pageoffset=1&listsize=100&objecttype=1&locationname=Düsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
"url": "https://www.immonet.de/immobiliensuche/beta?pageoffset=1&listsize=100&objecttype=1&locationname=D%C3%BCsseldorf&acid=&actype=&district=8717&district=8718&district=8719&district=8720&district=8721&district=8723&district=8724&district=8725&district=8727&district=8728&district=8729&district=8730&district=8731&district=8732&district=8733&district=8737&district=8738&district=8741&district=8745&district=8747&district=8750&district=8752&district=8754&district=8755&district=8756&district=8759&district=8760&district=8761&district=8763&district=8764&district=8765&ajaxIsRadiusActive=false&sortby=19&suchart=1&radius=0&pcatmtypes=1_1&pCatMTypeStoragefield=&parentcat=1&marketingtype=1&fromprice=&toprice=420000&fromarea=90&toarea=&fromplotarea=&toplotarea=&fromrooms=3&torooms=&objectcat=225&objectcat=18&objectcat=17&objectcat=12&objectcat=16&objectcat=181&objectcat=14&objectcat=15&objectcat=226&objectcat=13&wbs=-1&fromyear=&toyear=",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"immowelt": {
|
"immowelt": {
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"kleinanzeigen": {
|
"kleinanzeigen": {
|
||||||
"url": "https://www.ebay-kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
"url": "https://www.kleinanzeigen.de/s-immobilien/duesseldorf/anzeige:angebote/wohnung/k0c195l2068r5",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"neubauKompass": {
|
"neubauKompass": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import utils from '../../lib/utils.js';
|
import utils from '../../lib/utils.js';
|
||||||
import assert from 'assert';
|
import assert from 'assert';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
const expect = chai.expect;
|
|
||||||
const fakeWorkingHoursConfig = (from, to) => ({
|
const fakeWorkingHoursConfig = (from, to) => ({
|
||||||
workingHours: {
|
workingHours: {
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||||
import { get } from '../mocks/mockNotification.js';
|
import { get } from '../mocks/mockNotification.js';
|
||||||
import { mockFredy, providerConfig } from '../utils.js';
|
import { mockFredy, providerConfig } from '../utils.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import * as provider from '../../lib/provider/wgGesucht.js';
|
import * as provider from '../../lib/provider/wgGesucht.js';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('#wgGesucht testsuite()', () => {
|
describe('#wgGesucht testsuite()', () => {
|
||||||
after(() => {
|
after(() => {
|
||||||
similarityCache.stopCacheCleanup();
|
similarityCache.stopCacheCleanup();
|
||||||
@@ -23,7 +23,6 @@ describe('#wgGesucht testsuite()', () => {
|
|||||||
expect(notify.id).to.be.a('string');
|
expect(notify.id).to.be.a('string');
|
||||||
expect(notify.title).to.be.a('string');
|
expect(notify.title).to.be.a('string');
|
||||||
expect(notify.details).to.be.a('string');
|
expect(notify.details).to.be.a('string');
|
||||||
expect(notify.size).to.be.a('string');
|
|
||||||
expect(notify.price).to.be.a('string');
|
expect(notify.price).to.be.a('string');
|
||||||
expect(notify.link).to.be.a('string');
|
expect(notify.link).to.be.a('string');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import mutator from '../../lib/services/queryStringMutator.js';
|
import mutator from '../../lib/services/queryStringMutator.js';
|
||||||
import queryString from 'query-string';
|
import queryString from 'query-string';
|
||||||
const expect = chai.expect;
|
|
||||||
|
|
||||||
const data = await readFile(new URL('./testData.json', import.meta.url));
|
const data = await readFile(new URL('./testData.json', import.meta.url));
|
||||||
|
|
||||||
const testData = JSON.parse(data);
|
const testData = JSON.parse(data);
|
||||||
|
|
||||||
let _provider = await Promise.all(
|
let _provider = await Promise.all(
|
||||||
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`))
|
fs.readdirSync('./lib/provider/').map(async (integPath) => await import(`../../lib/provider/${integPath}`)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
||||||
import chai from 'chai';
|
import { expect } from 'chai';
|
||||||
const expect = chai.expect;
|
|
||||||
describe('similarityCheck', () => {
|
describe('similarityCheck', () => {
|
||||||
describe('#similarityCheck()', () => {
|
describe('#similarityCheck()', () => {
|
||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
@@ -29,10 +29,10 @@ describe('similarityCheck', () => {
|
|||||||
it('should be false', () => {
|
it('should be false', () => {
|
||||||
const check = new SimilarityCacheEntry(0);
|
const check = new SimilarityCacheEntry(0);
|
||||||
check.setCacheEntry(
|
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.'
|
'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(
|
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.'
|
'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.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import React, { useEffect } from 'react';
|
|||||||
import InsufficientPermission from './components/permission/InsufficientPermission';
|
import InsufficientPermission from './components/permission/InsufficientPermission';
|
||||||
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
|
||||||
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
import GeneralSettings from './views/generalSettings/GeneralSettings';
|
||||||
import ToastsContainer from './components/toasts/ToastContainer';
|
|
||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import ToastContext from './components/toasts/ToastContext';
|
|
||||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import useToast from './components/toasts/useToast';
|
|
||||||
import { Switch, Redirect } from 'react-router-dom';
|
import { Switch, Redirect } from 'react-router-dom';
|
||||||
import Logout from './components/logout/Logout';
|
import Logout from './components/logout/Logout';
|
||||||
import Logo from './components/logo/Logo';
|
import Logo from './components/logo/Logo';
|
||||||
@@ -23,20 +20,21 @@ import './App.less';
|
|||||||
|
|
||||||
export default function FredyApp() {
|
export default function FredyApp() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [showToast, onToastFinished, toasts] = useToast();
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await dispatch.provider.getProvider();
|
|
||||||
await dispatch.jobs.getJobs();
|
|
||||||
await dispatch.jobs.getProcessingTimes();
|
|
||||||
await dispatch.notificationAdapter.getAdapter();
|
|
||||||
await dispatch.user.getCurrentUser();
|
await dispatch.user.getCurrentUser();
|
||||||
|
if (!needsLogin()) {
|
||||||
|
await dispatch.provider.getProvider();
|
||||||
|
await dispatch.jobs.getJobs();
|
||||||
|
await dispatch.jobs.getProcessingTimes();
|
||||||
|
await dispatch.notificationAdapter.getAdapter();
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [currentUser?.userId]);
|
}, [currentUser?.userId]);
|
||||||
|
|
||||||
@@ -56,44 +54,41 @@ export default function FredyApp() {
|
|||||||
return loading ? null : needsLogin() ? (
|
return loading ? null : needsLogin() ? (
|
||||||
login()
|
login()
|
||||||
) : (
|
) : (
|
||||||
<ToastContext.Provider value={{ showToast }}>
|
<div className="app">
|
||||||
<div className="app">
|
<div className="app__container">
|
||||||
<div className="app__container">
|
<Logout />
|
||||||
<Logout />
|
<Logo width={190} white />
|
||||||
<Logo width={190} white />
|
<Menu isAdmin={isAdmin()} />
|
||||||
<Menu isAdmin={isAdmin()} />
|
<Switch>
|
||||||
<ToastsContainer toasts={toasts} onToastFinished={onToastFinished} />
|
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
||||||
<Switch>
|
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
||||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
||||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
||||||
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
||||||
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
<PermissionAwareRoute
|
||||||
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
name="Create new User"
|
||||||
<PermissionAwareRoute
|
path="/users/new"
|
||||||
name="Create new User"
|
component={<UserMutator />}
|
||||||
path="/users/new"
|
currentUser={currentUser}
|
||||||
component={<UserMutator />}
|
/>
|
||||||
currentUser={currentUser}
|
<PermissionAwareRoute
|
||||||
/>
|
name="Edit a user"
|
||||||
<PermissionAwareRoute
|
path="/users/edit/:userId"
|
||||||
name="Edit a user"
|
component={<UserMutator />}
|
||||||
path="/users/edit/:userId"
|
currentUser={currentUser}
|
||||||
component={<UserMutator />}
|
/>
|
||||||
currentUser={currentUser}
|
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
||||||
/>
|
<PermissionAwareRoute
|
||||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
name="General Settings"
|
||||||
<PermissionAwareRoute
|
path="/generalSettings"
|
||||||
name="General Settings"
|
component={<GeneralSettings />}
|
||||||
path="/generalSettings"
|
currentUser={currentUser}
|
||||||
component={<GeneralSettings />}
|
/>
|
||||||
currentUser={currentUser}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Redirect from="/" to={'/jobs'} />
|
<Redirect from="/" to={'/jobs'} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ToastContext.Provider>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
width:100%;
|
width:100%;
|
||||||
|
|
||||||
&__container {
|
&__container {
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
padding: 1rem 1rem;
|
padding: 1rem 1rem;
|
||||||
background-color: #595959f5;
|
color: var(--semi-color-text-0);
|
||||||
color: #f1f1f1;
|
background-color: #232429;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,4 +16,28 @@
|
|||||||
|
|
||||||
.ui.black.label, .ui.black.labels .label {
|
.ui.black.label, .ui.black.labels .label {
|
||||||
background-color: #31303078!important;
|
background-color: #31303078!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:visited {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:active {
|
||||||
|
color: #54a9ff;
|
||||||
|
background-color: transparent;
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
@@ -5,9 +5,11 @@ import { HashRouter } from 'react-router-dom';
|
|||||||
import { createHashHistory } from 'history';
|
import { createHashHistory } from 'history';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { createRoot } from 'react-dom/client';
|
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';
|
||||||
|
|
||||||
const container = document.getElementById('fredy');
|
const container = document.getElementById('fredy');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
|
|
||||||
const history = createHashHistory();
|
const history = createHashHistory();
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
@@ -17,7 +19,9 @@ import './Index.less';
|
|||||||
root.render(
|
root.render(
|
||||||
<Provider store={reduxStore}>
|
<Provider store={reduxStore}>
|
||||||
<HashRouter history={history}>
|
<HashRouter history={history}>
|
||||||
<App />
|
<LocaleProvider locale={en_US}>
|
||||||
|
<App />
|
||||||
|
</LocaleProvider>
|
||||||
</HashRouter>
|
</HashRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,5 +2,14 @@ body, html {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: #595959f5;
|
background-color: #232429;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-table-row-head{
|
||||||
|
background-color: #2b2b2b !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-table-row-cell {
|
||||||
|
background-color: #333333 !important;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header } from 'semantic-ui-react';
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './Headline.less';
|
export default function Headline({ text, size = 3 } = {}) {
|
||||||
|
const { Title } = Typography;
|
||||||
export default function Headline({ text, size = 'medium', className = '' } = {}) {
|
|
||||||
return (
|
return (
|
||||||
<Header className={`headline ${className}`} size={size}>
|
<Title heading={size} style={{ marginBottom: '1rem' }}>
|
||||||
{text}
|
{text}
|
||||||
</Header>
|
</Title>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
.headline{
|
|
||||||
color: #f1f1f1 !important;
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Button } from 'semantic-ui-react';
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
|
import { IconUser } from '@douyinfe/semi-icons';
|
||||||
const Logout = function Logout() {
|
const Logout = function Logout() {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
content="Logout"
|
icon={<IconUser />}
|
||||||
labelPosition="left"
|
type="danger"
|
||||||
icon="user"
|
theme="solid"
|
||||||
size="mini"
|
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await xhrPost('/api/login/logout');
|
await xhrPost('/api/login/logout');
|
||||||
location.reload();
|
location.reload();
|
||||||
}}
|
}}
|
||||||
negative
|
>
|
||||||
/>
|
Logout
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +1,54 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { Icon, Menu } from 'semantic-ui-react';
|
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './Menu.less';
|
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
function parsePathName(name) {
|
||||||
|
const split = name.split('/').filter((s) => s.length !== 0);
|
||||||
|
return '/' + split[0];
|
||||||
|
}
|
||||||
|
|
||||||
const TopMenu = function TopMenu({ isAdmin }) {
|
const TopMenu = function TopMenu({ isAdmin }) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
const isActiveRoute = (name) => location.pathname.indexOf(name) !== -1;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu pointing secondary className="topMenu">
|
<Tabs type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => history.push(key)}>
|
||||||
<Menu.Item
|
<TabPane
|
||||||
name="jobs"
|
itemKey="/jobs"
|
||||||
active={isActiveRoute('jobs')}
|
tab={
|
||||||
className={isActiveRoute('jobs') ? 'topMenu__active' : 'topMenu__item'}
|
<span>
|
||||||
onClick={() => history.push('/jobs')}
|
<IconTerminal />
|
||||||
>
|
Jobs
|
||||||
<Icon name="search" /> Job Configuration
|
</span>
|
||||||
</Menu.Item>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Menu.Item
|
<TabPane
|
||||||
name="user"
|
itemKey="/users"
|
||||||
active={isActiveRoute('users')}
|
tab={
|
||||||
className={isActiveRoute('users') ? 'topMenu__active' : 'topMenu__item'}
|
<span>
|
||||||
onClick={() => history.push('/users')}
|
<IconUser />
|
||||||
>
|
User
|
||||||
<Icon name="user" /> User configuration
|
</span>
|
||||||
</Menu.Item>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<Menu.Item
|
<TabPane
|
||||||
name="general"
|
itemKey="/generalSettings"
|
||||||
active={isActiveRoute('general')}
|
tab={
|
||||||
className={isActiveRoute('general') ? 'topMenu__active' : 'topMenu__item'}
|
<span>
|
||||||
onClick={() => history.push('/generalSettings')}
|
<IconSetting />
|
||||||
>
|
General
|
||||||
<Icon name="cog" /> General Settings
|
</span>
|
||||||
</Menu.Item>
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Menu>
|
</Tabs>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
.topMenu {
|
|
||||||
border-bottom: 1px solid #b7b7b7f2 !important;
|
|
||||||
|
|
||||||
&__active {
|
|
||||||
border-bottom: 1px solid #06dcfff2 !important;
|
|
||||||
font-weight: 550 !important;
|
|
||||||
color: #3ed7ff !important;
|
|
||||||
margin: 0 0 -1px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__item {
|
|
||||||
color: #fffffff2 !important;
|
|
||||||
border-color: transparent !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header } from 'semantic-ui-react';
|
|
||||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||||
|
|
||||||
export default function InsufficientPermission() {
|
export default function InsufficientPermission() {
|
||||||
@@ -7,9 +6,7 @@ export default function InsufficientPermission() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
||||||
<img src={insufficientPermission} height={250} />
|
<img src={insufficientPermission} height={250} />
|
||||||
<br />
|
<br />
|
||||||
<Header as="h4" inverted>
|
<h4>Insufficient permission :(</h4>
|
||||||
Insufficient permission :(
|
|
||||||
</Header>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Header, Icon, Popup, Segment } from 'semantic-ui-react';
|
import { Card } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './SegmentParts.less';
|
import './SegmentParts.less';
|
||||||
|
|
||||||
export const SegmentPart = ({ name, icon = null, children, helpText }) => (
|
export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
|
||||||
<Segment inverted>
|
const { Meta } = Card;
|
||||||
<Header as="h5" inverted sub>
|
|
||||||
{icon && <Icon name={icon} inverted size="mini" />}
|
|
||||||
<Header.Content>{name}</Header.Content>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Popup
|
return (
|
||||||
content={helpText}
|
<Card
|
||||||
trigger={
|
title={
|
||||||
<span className="generalSettings__help">
|
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
|
||||||
{' '}
|
|
||||||
<Icon name="help circle" inverted />
|
|
||||||
What is this?
|
|
||||||
</span>
|
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
<Segment inverted className="segmentParts">
|
|
||||||
{children}
|
{children}
|
||||||
</Segment>
|
</Card>
|
||||||
</Segment>
|
);
|
||||||
);
|
};
|
||||||
|
|||||||
@@ -1,66 +1,79 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { Button, Empty, Table, Switch } from '@douyinfe/semi-ui';
|
||||||
import Switch from 'react-switch';
|
import { IconDelete, IconEdit, IconHistogram } from '@douyinfe/semi-icons';
|
||||||
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
const emptyTable = () => {
|
const empty = (
|
||||||
return (
|
<Empty
|
||||||
<Table.Row>
|
image={<IllustrationNoResult />}
|
||||||
<Table.Cell collapsing colSpan={6} style={{ textAlign: 'center' }}>
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
No Data
|
description={'No jobs available'}
|
||||||
</Table.Cell>
|
/>
|
||||||
</Table.Row>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight) => {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{Object.keys(jobs).map((jobKey) => {
|
|
||||||
const job = jobs[jobKey];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.Row key={jobKey}>
|
|
||||||
<Table.Cell collapsing>
|
|
||||||
<Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>{job.name}</Table.Cell>
|
|
||||||
<Table.Cell>{job.numberOfFoundListings || 0}</Table.Cell>
|
|
||||||
<Table.Cell>{job.provider.length || 0}</Table.Cell>
|
|
||||||
<Table.Cell>{job.notificationAdapter.length || 0}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="teal" icon="chart line" onClick={() => onJobInsight(job.id)} />
|
|
||||||
<Button circular color="blue" icon="edit" onClick={() => onJobEdit(job.id)} />
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onJobRemoval(job.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table singleLine inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={empty}
|
||||||
<Table.HeaderCell />
|
columns={[
|
||||||
<Table.HeaderCell>Job Name</Table.HeaderCell>
|
{
|
||||||
<Table.HeaderCell>Number of findings</Table.HeaderCell>
|
title: '',
|
||||||
<Table.HeaderCell>Active provider</Table.HeaderCell>
|
dataIndex: '',
|
||||||
<Table.HeaderCell>Active notification adapter</Table.HeaderCell>
|
render: (job) => {
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
return <Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />;
|
||||||
</Table.Row>
|
},
|
||||||
</Table.Header>
|
},
|
||||||
|
{
|
||||||
<Table.Body>
|
title: 'Job Name',
|
||||||
{Object.keys(jobs).length === 0
|
dataIndex: 'name',
|
||||||
? emptyTable()
|
},
|
||||||
: content(jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight)}
|
{
|
||||||
</Table.Body>
|
title: 'Number of findings',
|
||||||
</Table>
|
dataIndex: 'numberOfFoundListings',
|
||||||
|
render: (value) => {
|
||||||
|
return value || 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active provider',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
render: (value) => {
|
||||||
|
return value.length || 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Active notification adapter',
|
||||||
|
dataIndex: 'notificationAdapter',
|
||||||
|
render: (value) => {
|
||||||
|
return value.length || 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'tools',
|
||||||
|
render: (_, job) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<IconHistogram />}
|
||||||
|
onClick={() => onJobInsight(job.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={() => onJobEdit(job.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onJobRemoval(job.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={jobs}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,38 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||||
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
const emptyTable = () => {
|
|
||||||
return (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
|
||||||
No Data
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (adapterData, onRemove, onEdit) => {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{adapterData.map((data) => {
|
|
||||||
return (
|
|
||||||
<Table.Row key={data.id}>
|
|
||||||
<Table.Cell>{data.name}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="blue" icon="edit" onClick={() => onEdit(data.id)} />
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table singleLine inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={<Empty description="No Data" />}
|
||||||
<Table.HeaderCell>Notification Adapter Name</Table.HeaderCell>
|
columns={[
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
{
|
||||||
</Table.Row>
|
title: 'Notification Adapter Name',
|
||||||
</Table.Header>
|
dataIndex: 'name',
|
||||||
|
},
|
||||||
|
|
||||||
<Table.Body>
|
{
|
||||||
{notificationAdapter.length === 0 ? emptyTable() : content(notificationAdapter, onRemove, onEdit)}
|
title: '',
|
||||||
</Table.Body>
|
dataIndex: 'tools',
|
||||||
</Table>
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="secondary"
|
||||||
|
icon={<IconEdit />}
|
||||||
|
onClick={() => onEdit(record.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={notificationAdapter}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,42 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui';
|
||||||
|
import { IconDelete } from '@douyinfe/semi-icons';
|
||||||
const emptyTable = () => {
|
|
||||||
return (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
|
|
||||||
No Data
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (providerData, onRemove) => {
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{providerData.map((data) => {
|
|
||||||
return (
|
|
||||||
<Table.Row key={data.id}>
|
|
||||||
<Table.Cell>{data.name}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
|
||||||
Visit site
|
|
||||||
</a>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
export default function ProviderTable({ providerData = [], onRemove } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table singleLine inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={<Empty description="No Provider available" />}
|
||||||
<Table.HeaderCell>Provider Name</Table.HeaderCell>
|
columns={[
|
||||||
<Table.HeaderCell>Url</Table.HeaderCell>
|
{
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
title: 'Provider Name',
|
||||||
</Table.Row>
|
dataIndex: 'name',
|
||||||
</Table.Header>
|
},
|
||||||
|
{
|
||||||
<Table.Body>{providerData.length === 0 ? emptyTable() : content(providerData, onRemove)}</Table.Body>
|
title: 'Provider Url',
|
||||||
</Table>
|
dataIndex: 'url',
|
||||||
|
render: (_, data) => {
|
||||||
|
return (
|
||||||
|
<a href={data.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
Visit site
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'tools',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button type="danger" icon={<IconDelete />} onClick={() => onRemove(record.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={providerData}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,58 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Table, Button } from 'semantic-ui-react';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
|
import { Table, Button, Empty } from '@douyinfe/semi-ui';
|
||||||
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const emptyTable = () => {
|
const empty = (
|
||||||
return (
|
<Empty
|
||||||
<Table.Row>
|
image={<IllustrationNoResult />}
|
||||||
<Table.Cell collapsing colSpan={4} style={{ textAlign: 'center' }}>
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
No Data
|
description={'No user available'}
|
||||||
</Table.Cell>
|
/>
|
||||||
</Table.Row>
|
);
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (user, onUserRemoval, onUserEdit) => {
|
|
||||||
return user.map((user) => {
|
|
||||||
return (
|
|
||||||
<Table.Row key={user.id}>
|
|
||||||
<Table.Cell>{user.username}</Table.Cell>
|
|
||||||
<Table.Cell>{user.lastLogin == null ? '---' : format(user.lastLogin)}</Table.Cell>
|
|
||||||
<Table.Cell>{user.numberOfJobs}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
<div style={{ float: 'right' }}>
|
|
||||||
<Button circular color="red" icon="trash" onClick={() => onUserRemoval(user.id)} />
|
|
||||||
<Button circular color="blue" icon="edit" onClick={() => onUserEdit(user.id)} />
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||||
return (
|
return (
|
||||||
<Table inverted>
|
<Table
|
||||||
<Table.Header>
|
pagination={false}
|
||||||
<Table.Row>
|
empty={empty}
|
||||||
<Table.HeaderCell>Username</Table.HeaderCell>
|
columns={[
|
||||||
<Table.HeaderCell>Last login</Table.HeaderCell>
|
{
|
||||||
<Table.HeaderCell>Number of jobs</Table.HeaderCell>
|
title: 'Username',
|
||||||
<Table.HeaderCell></Table.HeaderCell>
|
dataIndex: 'username',
|
||||||
</Table.Row>
|
},
|
||||||
</Table.Header>
|
{
|
||||||
|
title: 'Last login',
|
||||||
<Table.Body>{user.length === 0 ? emptyTable() : content(user, onUserRemoval, onUserEdit)}</Table.Body>
|
dataIndex: 'lastLogin',
|
||||||
</Table>
|
render: (value) => {
|
||||||
|
return format(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Number of jobs',
|
||||||
|
dataIndex: 'numberOfJobs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'tools',
|
||||||
|
render: (value, user) => {
|
||||||
|
return (
|
||||||
|
<div style={{ float: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
onClick={() => onUserRemoval(user.id)}
|
||||||
|
style={{ marginRight: '1rem' }}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dataSource={user}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
import './Toasts.css';
|
|
||||||
|
|
||||||
export default function Toast({ id, delay = 5500, message, onHide, backgroundColor, color, title }) {
|
|
||||||
const [className, setClassname] = React.useState('toast-container show-toast');
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
let hideTimeout = null;
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
setClassname('toast-container hide-toast');
|
|
||||||
hideTimeout = setTimeout(() => {
|
|
||||||
onHide && onHide(id);
|
|
||||||
}, 500);
|
|
||||||
}, delay);
|
|
||||||
return () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
clearTimeout(hideTimeout);
|
|
||||||
};
|
|
||||||
}, [id, delay, onHide]);
|
|
||||||
return (
|
|
||||||
<div className={className} style={{ backgroundColor, color }}>
|
|
||||||
<h5>{title}</h5>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import Toast from './Toast';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function ToastsContainer({ toasts, onToastFinished }) {
|
|
||||||
return (
|
|
||||||
<div className="toasts-container">
|
|
||||||
{toasts.map((toast, index) => (
|
|
||||||
<Toast key={index} {...toast} onHide={onToastFinished} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
const CheckoutDrawerContext = createContext({
|
|
||||||
showToast: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default CheckoutDrawerContext;
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
.toasts-container {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 65535;
|
|
||||||
right: 0;
|
|
||||||
max-width: 250px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toasts-container > .toast-container {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toasts-container:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container {
|
|
||||||
visibility: hidden;
|
|
||||||
position: relative;
|
|
||||||
z-index: 65535;
|
|
||||||
right: -1000px;
|
|
||||||
|
|
||||||
background-color: skyblue;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
|
|
||||||
min-width: 10rem;
|
|
||||||
min-height: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container.show-toast {
|
|
||||||
visibility: visible;
|
|
||||||
right: 24px;
|
|
||||||
animation: slidein 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast-container.hide-toast {
|
|
||||||
visibility: visible;
|
|
||||||
animation: slideout 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slidein {
|
|
||||||
from {
|
|
||||||
right: -1000px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
right: 24px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideout {
|
|
||||||
from {
|
|
||||||
right: 24px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
right: -1000px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function useToast() {
|
|
||||||
const [toasts, setToasts] = React.useState([]);
|
|
||||||
|
|
||||||
const showToast = ({ message, delay, color, backgroundColor, title }) => {
|
|
||||||
const toast = {
|
|
||||||
id: toasts.length,
|
|
||||||
message,
|
|
||||||
delay,
|
|
||||||
backgroundColor,
|
|
||||||
color,
|
|
||||||
title,
|
|
||||||
};
|
|
||||||
setToasts([...toasts, toast].reverse());
|
|
||||||
};
|
|
||||||
|
|
||||||
const onToastFinished = (id) => {
|
|
||||||
setToasts(toasts.filter((toast) => toast.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
return [showToast, onToastFinished, toasts];
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,32 @@ import React from 'react';
|
|||||||
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { Button, Form, Icon, Message, Segment, Radio } from 'semantic-ui-react';
|
import { Divider, Input, Radio, TimePicker, Button, RadioGroup } from '@douyinfe/semi-ui';
|
||||||
import ToastContext from '../../components/toasts/ToastContext';
|
import { InputNumber } from '@douyinfe/semi-ui';
|
||||||
import Headline from '../../components/headline/Headline';
|
import Headline from '../../components/headline/Headline';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { SegmentPart } from '../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../components/segment/SegmentPart';
|
||||||
|
import { Banner, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { IconSave, IconCalendar, IconKey, IconRefresh, IconSignal } from '@douyinfe/semi-icons';
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
const GeneralSettings = function Users() {
|
function formatFromTimestamp(ts) {
|
||||||
|
const date = new Date(ts);
|
||||||
|
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFromTBackend(time) {
|
||||||
|
if (time == null || time.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const date = new Date();
|
||||||
|
const split = time.split(':');
|
||||||
|
date.setHours(split[0]);
|
||||||
|
date.setMinutes(split[1]);
|
||||||
|
return date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
const GeneralSettings = function GeneralSettings() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
@@ -21,13 +39,12 @@ const GeneralSettings = function Users() {
|
|||||||
const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
|
const [scrapingAntProxy, setScrapingAntProxy] = React.useState('');
|
||||||
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
const [workingHourFrom, setWorkingHourFrom] = React.useState(null);
|
||||||
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
const [workingHourTo, setWorkingHourTo] = React.useState(null);
|
||||||
const ctx = React.useContext(ToastContext);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
await dispatch.generalSettings.getGeneralSettings();
|
await dispatch.generalSettings.getGeneralSettings();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -40,19 +57,18 @@ const GeneralSettings = function Users() {
|
|||||||
setWorkingHourTo(settings?.workingHours?.to);
|
setWorkingHourTo(settings?.workingHours?.to);
|
||||||
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
|
setScrapingAntProxy(settings?.scrapingAnt?.proxy || 'datacenter');
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const throwMessage = (message, type) => {
|
const throwMessage = (message, type) => {
|
||||||
ctx.showToast({
|
if (type === 'error') {
|
||||||
title: type === 'error' ? 'Error' : 'Success',
|
Toast.error(message);
|
||||||
message: message,
|
} else {
|
||||||
delay: 5000,
|
Toast.success(message);
|
||||||
backgroundColor: type === 'error' ? '#db2828' : '#87eb8f',
|
}
|
||||||
color: type === 'error' ? '#fff' : '#000',
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onStore = async () => {
|
const onStore = async () => {
|
||||||
@@ -97,139 +113,130 @@ const GeneralSettings = function Users() {
|
|||||||
{!loading && (
|
{!loading && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Headline text="General Settings" />
|
<Headline text="General Settings" />
|
||||||
<Message className="generalSettings__message">
|
<Banner
|
||||||
<h5>
|
fullMode={false}
|
||||||
<Icon name="info circle" />
|
type="info"
|
||||||
Info
|
closeIcon={null}
|
||||||
</h5>
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Info</div>}
|
||||||
<p>If you change any settings, you must restart Fredy afterwards.</p>
|
style={{ marginBottom: '1rem' }}
|
||||||
</Message>
|
description="If you change any settings, you must restart Fredy afterwards."
|
||||||
<Form>
|
/>
|
||||||
|
<div>
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Interval"
|
name="Interval"
|
||||||
helpText="Interval in minutes for running queries against the configured services."
|
helpText="Interval in minutes for running queries against the configured services."
|
||||||
icon="refresh"
|
Icon={IconRefresh}
|
||||||
>
|
>
|
||||||
<Form.Input
|
<InputNumber
|
||||||
type="number"
|
min={0}
|
||||||
min="0"
|
max={1440}
|
||||||
max="1440"
|
|
||||||
placeholder="Interval in minutes"
|
placeholder="Interval in minutes"
|
||||||
inverted
|
value={interval}
|
||||||
size="mini"
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
width={6}
|
onChange={(value) => setInterval(value)}
|
||||||
defaultValue={interval}
|
suffix={'minutes'}
|
||||||
onChange={(e) => setInterval(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Port" helpText="Port on which Fredy is running." icon="connectdevelop">
|
<SegmentPart name="Port" helpText="Port on which Fredy is running." Icon={IconSignal}>
|
||||||
<Form.Input
|
<InputNumber
|
||||||
type="number"
|
min={0}
|
||||||
min="0"
|
max={99999}
|
||||||
max="99999"
|
|
||||||
placeholder="Port"
|
placeholder="Port"
|
||||||
inverted
|
value={port}
|
||||||
size="mini"
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
width={6}
|
onChange={(value) => setPort(value)}
|
||||||
defaultValue={port}
|
|
||||||
onChange={(e) => setPort(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="ScrapingAnt Api Key"
|
name="ScrapingAnt Api Key"
|
||||||
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
|
helpText="The api key for ScrapingAnt is used to be able to scrape Immoscout."
|
||||||
icon="key"
|
Icon={IconKey}
|
||||||
>
|
>
|
||||||
<Form.Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="ScrapingAnt Api Key"
|
placeholder="ScrapingAnt Api Key"
|
||||||
inverted
|
value={scrapingAntApiKey}
|
||||||
size="mini"
|
onChange={(val) => setScrapingAntApiKey(val)}
|
||||||
width={6}
|
|
||||||
defaultValue={scrapingAntApiKey}
|
|
||||||
onChange={(e) => setScrapingAntApiKey(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="ScrapingAnt proxy settings"
|
name="ScrapingAnt proxy settings"
|
||||||
helpText="Scraping ant provides different proxies."
|
helpText="Scraping ant provides different proxies."
|
||||||
icon="key"
|
Icon={IconKey}
|
||||||
>
|
>
|
||||||
<Message info>
|
<Banner
|
||||||
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies.{' '}
|
fullMode={false}
|
||||||
<br />
|
type="info"
|
||||||
<h4>Datacenter-Proxy</h4>
|
closeIcon={null}
|
||||||
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and more
|
title={
|
||||||
likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
|
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>
|
||||||
<h4>Residential-Proxy</h4>
|
ScrapingAnt is needed to scrape Immoscout. ScrapingAnt itself is using 2 different types of proxies
|
||||||
High-quality proxy server located in one of the real people houses across the world. Datacenter proxies
|
</div>
|
||||||
are faster and more likely to success, but they are more expensive. A call with a datacenter proxy cost
|
}
|
||||||
250 credits.
|
style={{ marginBottom: '1rem' }}
|
||||||
<br />
|
description={
|
||||||
<br />
|
<div>
|
||||||
<b>
|
<h4>Datacenter-Proxy</h4>
|
||||||
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only successful
|
Proxy server located in one of the datacenters across the world. Datacenter proxies are slower and
|
||||||
calls will be charged.
|
more likely to fail, but they are cheaper. A call with a datacenter proxy cost 10 credits.
|
||||||
</b>
|
<h4>Residential-Proxy</h4>
|
||||||
</Message>
|
High-quality proxy server located in one of the real people houses across the world. Datacenter
|
||||||
<Form.Field>
|
proxies are faster and more likely to success, but they are more expensive. A call with a datacenter
|
||||||
<Radio
|
proxy cost 250 credits.
|
||||||
label="Datacenter proxy"
|
<br />
|
||||||
name="scrapingAntProxy"
|
<br />
|
||||||
value="datacenter"
|
<b>
|
||||||
checked={scrapingAntProxy === 'datacenter'}
|
On the free tier, you have 10.000 credits, so chose your option wisely. Keep in mind, only
|
||||||
onChange={(e, { value }) => setScrapingAntProxy(value)}
|
successful calls will be charged.
|
||||||
/>
|
</b>
|
||||||
</Form.Field>
|
</div>
|
||||||
<Form.Field>
|
}
|
||||||
<Radio
|
/>
|
||||||
label="Residential proxy"
|
|
||||||
name="scrapingAntProxy"
|
|
||||||
value="residential"
|
|
||||||
checked={scrapingAntProxy === 'residential'}
|
|
||||||
onChange={(e, { value }) => setScrapingAntProxy(value)}
|
|
||||||
/>
|
|
||||||
</Form.Field>
|
|
||||||
</SegmentPart>
|
|
||||||
|
|
||||||
|
<RadioGroup value={scrapingAntProxy} onChange={(e) => setScrapingAntProxy(e.target.value)}>
|
||||||
|
<Radio name="datacenter" value="datacenter" checked={scrapingAntProxy === 'datacenter'}>
|
||||||
|
Datacenter proxy
|
||||||
|
</Radio>
|
||||||
|
<Radio name="residential" value="residential" checked={scrapingAntProxy === 'residential'}>
|
||||||
|
Residential proxy
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Working hours"
|
name="Working hours"
|
||||||
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
helpText="During this hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
|
||||||
icon="calendar outline"
|
Icon={IconCalendar}
|
||||||
>
|
>
|
||||||
<div className="generalSettings__timePickerContainer">
|
<div className="generalSettings__timePickerContainer">
|
||||||
<Form.Input
|
<TimePicker
|
||||||
className="generalSettings__time"
|
format={'HH:mm'}
|
||||||
type="time"
|
insetLabel="From"
|
||||||
placeholder="Working hours from"
|
value={formatFromTBackend(workingHourFrom)}
|
||||||
inverted
|
placeholder=""
|
||||||
size="mini"
|
onChange={(val) => {
|
||||||
width={2}
|
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val));
|
||||||
defaultValue={workingHourFrom}
|
}}
|
||||||
onChange={(e) => setWorkingHourFrom(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<div className="generalSettings__until">until</div>
|
<TimePicker
|
||||||
<Form.Input
|
format={'HH:mm'}
|
||||||
type="time"
|
insetLabel="Until"
|
||||||
placeholder="Working hours to"
|
value={formatFromTBackend(workingHourTo)}
|
||||||
inverted
|
placeholder=""
|
||||||
size="mini"
|
onChange={(val) => {
|
||||||
width={2}
|
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
|
||||||
defaultValue={workingHourTo}
|
}}
|
||||||
onChange={(e) => setWorkingHourTo(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<Segment inverted floated="right">
|
<Button type="primary" theme="solid" onClick={onStore} icon={<IconSave />}>
|
||||||
<Button color="teal" onClick={onStore}>
|
Save
|
||||||
Save
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</Segment>
|
|
||||||
</Form>
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,11 +2,7 @@
|
|||||||
&__timePickerContainer {
|
&__timePickerContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
}
|
gap: 1rem;
|
||||||
|
|
||||||
&__until {
|
|
||||||
margin-left: 1rem;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__help{
|
&__help{
|
||||||
@@ -14,8 +10,4 @@
|
|||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__message{
|
|
||||||
background: #8fe8ff!important;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ToastContext from '../../components/toasts/ToastContext';
|
|
||||||
import JobTable from '../../components/table/JobTable';
|
import JobTable from '../../components/table/JobTable';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
import { xhrDelete, xhrPut } from '../../services/xhr';
|
||||||
import { Button, Icon } from 'semantic-ui-react';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import ProcessingTimes from './ProcessingTimes';
|
import ProcessingTimes from './ProcessingTimes';
|
||||||
|
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||||
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
import './Jobs.less';
|
import './Jobs.less';
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
@@ -15,49 +14,24 @@ export default function Jobs() {
|
|||||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const ctx = React.useContext(ToastContext);
|
|
||||||
|
|
||||||
const onJobRemoval = async (jobId) => {
|
const onJobRemoval = async (jobId) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
ctx.showToast({
|
Toast.success('Job successfully remove');
|
||||||
title: 'Success',
|
|
||||||
message: 'Job successfully remove',
|
|
||||||
delay: 5000,
|
|
||||||
backgroundColor: '#87eb8f',
|
|
||||||
color: '#000',
|
|
||||||
});
|
|
||||||
await dispatch.jobs.getJobs();
|
await dispatch.jobs.getJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.showToast({
|
Toast.error(error);
|
||||||
title: 'Error',
|
|
||||||
message: error,
|
|
||||||
delay: 35000,
|
|
||||||
backgroundColor: '#db2828',
|
|
||||||
color: '#fff',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onJobStatusChanged = async (jobId, status) => {
|
const onJobStatusChanged = async (jobId, status) => {
|
||||||
try {
|
try {
|
||||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||||
ctx.showToast({
|
Toast.success('Job status successfully changed');
|
||||||
title: 'Success',
|
|
||||||
message: 'Job status successfully changed',
|
|
||||||
delay: 5000,
|
|
||||||
backgroundColor: '#87eb8f',
|
|
||||||
color: '#000',
|
|
||||||
});
|
|
||||||
await dispatch.jobs.getJobs();
|
await dispatch.jobs.getJobs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.showToast({
|
Toast.error(error);
|
||||||
title: 'Error',
|
|
||||||
message: error,
|
|
||||||
delay: 35000,
|
|
||||||
backgroundColor: '#db2828',
|
|
||||||
color: '#fff',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,8 +39,12 @@ export default function Jobs() {
|
|||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
|
||||||
<Button primary className="jobs__newButton" onClick={() => history.push('/jobs/new')}>
|
<Button
|
||||||
<Icon name="plus" />
|
type="primary"
|
||||||
|
icon={<IconPlusCircle />}
|
||||||
|
className="jobs__newButton"
|
||||||
|
onClick={() => history.push('/jobs/new')}
|
||||||
|
>
|
||||||
New Job
|
New Job
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,51 +1,67 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Header, Label, Message, Segment } from 'semantic-ui-react';
|
import { Card, Descriptions, Divider } from '@douyinfe/semi-ui';
|
||||||
|
import { IconBolt } from '@douyinfe/semi-icons';
|
||||||
export default function ProcessingTimes({ processingTimes }) {
|
export default function ProcessingTimes({ processingTimes }) {
|
||||||
|
const { Meta } = Card;
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<>
|
||||||
<div>
|
<Descriptions
|
||||||
<Label as="span" color="black">
|
row
|
||||||
Processing Interval:
|
size="small"
|
||||||
<Label.Detail>{processingTimes.interval} min</Label.Detail>
|
style={{
|
||||||
</Label>
|
backgroundColor: '#35363c',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
|
||||||
{processingTimes.lastRun && (
|
{processingTimes.lastRun && (
|
||||||
<React.Fragment>
|
<>
|
||||||
<Label as="span" color="black">
|
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
|
||||||
Last run:
|
<Descriptions.Item itemKey="Next run">
|
||||||
<Label.Detail>{format(processingTimes.lastRun)}</Label.Detail>
|
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
|
||||||
</Label>
|
</Descriptions.Item>
|
||||||
<Label as="span" color="black">
|
</>
|
||||||
Next run:
|
|
||||||
<Label.Detail>{format(processingTimes.lastRun + processingTimes.interval * 60000)}</Label.Detail>
|
|
||||||
</Label>
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</Descriptions>
|
||||||
|
|
||||||
{processingTimes.scrapingAntData != null && (
|
{processingTimes.scrapingAntData != null && (
|
||||||
<Segment inverted>
|
<>
|
||||||
<Header as="h5">Remaining ScrapingAnt calls</Header>
|
<Divider margin="1rem" />
|
||||||
<Message.List>
|
<Card
|
||||||
<Message.Item>Plan: {processingTimes.scrapingAntData.plan_name}</Message.Item>
|
style={{ backgroundColor: '#35363c' }}
|
||||||
<Message.Item>
|
title={
|
||||||
|
<Meta
|
||||||
|
title="Remaining ScrapingAnt calls"
|
||||||
|
description="Information about your Scraping Ant Plan"
|
||||||
|
avatar={<IconBolt />}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>Plan: {processingTimes.scrapingAntData.plan_name}</p>
|
||||||
|
<p>
|
||||||
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
|
Duration: {format(new Date(processingTimes.scrapingAntData.start_date))} -{' '}
|
||||||
{format(new Date(processingTimes.scrapingAntData.end_date))}
|
{format(new Date(processingTimes.scrapingAntData.end_date))}
|
||||||
</Message.Item>
|
<br />
|
||||||
<Message.Item>
|
|
||||||
Credits: {processingTimes.scrapingAntData.remained_credits}/
|
Credits: {processingTimes.scrapingAntData.remained_credits}/
|
||||||
{processingTimes.scrapingAntData.plan_total_credits} (250 credits per call)
|
{processingTimes.scrapingAntData.plan_total_credits} (250 credits per call)
|
||||||
</Message.Item>
|
</p>
|
||||||
</Message.List>
|
If you want to scrape Immoscout or Immonet more often, you have to purchase a premium account of{' '}
|
||||||
If you want to scrape Immoscout more often, you have to purchase a premium account of{' '}
|
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
|
||||||
<a href="https://scrapingant.com/" target="_blank" rel="noreferrer">
|
ScrapingAnt
|
||||||
{' '}
|
</a>
|
||||||
ScrapingAnt
|
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to
|
||||||
</a>
|
recommend ScrapingAnt.)
|
||||||
. You can use the code <b>FREDY10</b> to get 10% off. (No affiliation, we are <b>not</b> getting paid to
|
</Card>
|
||||||
recommend ScrapingAnt.)
|
</>
|
||||||
</Segment>
|
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
*/
|
||||||
|
|||||||
@@ -2,19 +2,17 @@ import React, { Fragment, useState } from 'react';
|
|||||||
|
|
||||||
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
|
||||||
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
|
||||||
import { Icon, Form, Button, Label } from 'semantic-ui-react';
|
|
||||||
import ProviderTable from '../../../components/table/ProviderTable';
|
import ProviderTable from '../../../components/table/ProviderTable';
|
||||||
import ProviderMutator from './components/provider/ProviderMutator';
|
import ProviderMutator from './components/provider/ProviderMutator';
|
||||||
import ToastContext from '../../../components/toasts/ToastContext';
|
|
||||||
import Headline from '../../../components/headline/Headline';
|
import Headline from '../../../components/headline/Headline';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { xhrPost } from '../../../services/xhr';
|
import { xhrPost } from '../../../services/xhr';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
||||||
import './JobMutation.less';
|
import './JobMutation.less';
|
||||||
import Switch from 'react-switch';
|
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function JobMutator() {
|
export default function JobMutator() {
|
||||||
const jobs = useSelector((state) => state.jobs.jobs);
|
const jobs = useSelector((state) => state.jobs.jobs);
|
||||||
@@ -38,7 +36,6 @@ export default function JobMutator() {
|
|||||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const ctx = React.useContext(ToastContext);
|
|
||||||
|
|
||||||
const isSavingEnabled = () => {
|
const isSavingEnabled = () => {
|
||||||
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
|
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
|
||||||
@@ -55,24 +52,11 @@ export default function JobMutator() {
|
|||||||
jobId: jobToBeEdit?.id || null,
|
jobId: jobToBeEdit?.id || null,
|
||||||
});
|
});
|
||||||
await dispatch.jobs.getJobs();
|
await dispatch.jobs.getJobs();
|
||||||
ctx.showToast({
|
Toast.success('Job successfully saved...');
|
||||||
title: 'Success',
|
|
||||||
message: 'Job successfully saved...',
|
|
||||||
delay: 5000,
|
|
||||||
backgroundColor: '#87eb8f',
|
|
||||||
color: '#000',
|
|
||||||
});
|
|
||||||
history.push('/jobs');
|
history.push('/jobs');
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error(Exception.json.message);
|
console.error(Exception.json.message);
|
||||||
|
Toast.error(Exception.json != null ? Exception.json.message : Exception);
|
||||||
ctx.showToast({
|
|
||||||
title: 'Error',
|
|
||||||
message: Exception.json != null ? Exception.json.message : Exception,
|
|
||||||
delay: 8000,
|
|
||||||
backgroundColor: '#db2828',
|
|
||||||
color: '#fff',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,20 +91,19 @@ export default function JobMutator() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
|
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
|
||||||
<Form>
|
<form>
|
||||||
<SegmentPart name="Name">
|
<SegmentPart name="Name">
|
||||||
<Form.Input
|
<Input
|
||||||
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
autoFocus
|
|
||||||
inverted
|
|
||||||
width={6}
|
width={6}
|
||||||
defaultValue={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(value) => setName(value)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Provider"
|
name="Provider"
|
||||||
icon="briefcase"
|
icon="briefcase"
|
||||||
@@ -130,10 +113,14 @@ export default function JobMutator() {
|
|||||||
'to search for new listings.'
|
'to search for new listings.'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form.Button primary className="jobMutation__newButton" onClick={() => setProviderCreationVisibility(true)}>
|
<Button
|
||||||
<Icon name="plus" />
|
type="primary"
|
||||||
|
icon={<IconPlusCircle />}
|
||||||
|
className="jobMutation__newButton"
|
||||||
|
onClick={() => setProviderCreationVisibility(true)}
|
||||||
|
>
|
||||||
Add new Provider
|
Add new Provider
|
||||||
</Form.Button>
|
</Button>
|
||||||
|
|
||||||
<ProviderTable
|
<ProviderTable
|
||||||
providerData={providerData}
|
providerData={providerData}
|
||||||
@@ -142,20 +129,20 @@ export default function JobMutator() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="bell"
|
icon="bell"
|
||||||
name="Notification Adapter"
|
name="Notification Adapter"
|
||||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
||||||
>
|
>
|
||||||
<Form.Button
|
<Button
|
||||||
primary
|
type="primary"
|
||||||
className="jobMutation__newButton"
|
className="jobMutation__newButton"
|
||||||
|
icon={<IconPlusCircle />}
|
||||||
onClick={() => setNotificationCreationVisibility(true)}
|
onClick={() => setNotificationCreationVisibility(true)}
|
||||||
>
|
>
|
||||||
<Icon name="plus" />
|
|
||||||
Add new Notification Adapter
|
Add new Notification Adapter
|
||||||
</Form.Button>
|
</Button>
|
||||||
|
|
||||||
<NotificationAdapterTable
|
<NotificationAdapterTable
|
||||||
notificationAdapter={notificationAdapterData}
|
notificationAdapter={notificationAdapterData}
|
||||||
@@ -169,40 +156,19 @@ export default function JobMutator() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="bell"
|
icon="bell"
|
||||||
name="Blacklist"
|
name="Blacklist"
|
||||||
helpText="If a listing contains one of these words, it will be filtered out. Words must be comma separated. To remove a word from the black list, just click the red label(s)."
|
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
||||||
>
|
>
|
||||||
<Form.Input
|
<TagInput
|
||||||
type="text"
|
value={blacklist || []}
|
||||||
maxLength={40}
|
placeholder="Add a word for filtering..."
|
||||||
placeholder="Comma separated list of blacklisted words"
|
onChange={(v) => setBlacklist([...v])}
|
||||||
autoFocus
|
|
||||||
inverted
|
|
||||||
width={6}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.value.indexOf(',') !== -1) {
|
|
||||||
setBlacklist([...blacklist, e.target.value.replace(',', '')]);
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{blacklist.map((blacklistWord) => (
|
|
||||||
<Label
|
|
||||||
as="a"
|
|
||||||
key={`blacklist_${blacklistWord}`}
|
|
||||||
onClick={(e, obj) => {
|
|
||||||
setBlacklist(blacklist.filter((word) => word !== obj.content));
|
|
||||||
}}
|
|
||||||
content={blacklistWord}
|
|
||||||
icon="thumbs down"
|
|
||||||
color="red"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
icon="play circle outline"
|
icon="play circle outline"
|
||||||
name="Job activation"
|
name="Job activation"
|
||||||
@@ -210,14 +176,14 @@ export default function JobMutator() {
|
|||||||
>
|
>
|
||||||
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<Button color="red" onClick={() => history.push('/jobs')}>
|
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/jobs')}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="green" disabled={!isSavingEnabled()} onClick={mutateJob}>
|
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</form>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
.jobMutation {
|
.jobMutation {
|
||||||
&__newButton{
|
&__newButton{
|
||||||
float: right;
|
float: right;
|
||||||
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.semi-select-option-list-wrapper {
|
||||||
|
width: 25rem;
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
|
||||||
import { Modal, Form, Button, Dropdown, Input, Message } from 'semantic-ui-react';
|
|
||||||
import { xhrPost } from '../../../../../services/xhr';
|
import { xhrPost } from '../../../../../services/xhr';
|
||||||
import Help from './NotificationHelpDisplay';
|
import Help from './NotificationHelpDisplay';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import Switch from 'react-switch';
|
import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './NotificationAdapterMutator.less';
|
import './NotificationAdapterMutator.less';
|
||||||
|
|
||||||
@@ -26,9 +25,12 @@ const validate = (selectedAdapter) => {
|
|||||||
results.push('All fields are mandatory and must be set.');
|
results.push('All fields are mandatory and must be set.');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (uiElement.type === 'number' && (typeof uiElement.value !== 'number' || uiElement.value < 0)) {
|
if (uiElement.type === 'number') {
|
||||||
results.push('A number field cannot contain anything else and must be > 0.');
|
const numberValue = parseFloat(uiElement.value);
|
||||||
continue;
|
if(isNaN(numberValue) || numberValue < 0) {
|
||||||
|
results.push('A number field cannot contain anything else and must be > 0.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
||||||
results.push('A boolean field cannot be of a different type.');
|
results.push('A boolean field cannot be of a different type.');
|
||||||
@@ -138,8 +140,7 @@ export default function NotificationAdapterMutator({
|
|||||||
const uiElement = selectedAdapter.fields[key];
|
const uiElement = selectedAdapter.fields[key];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Field key={uiElement.description}>
|
<Form key={key}>
|
||||||
<label>{uiElement.label}:</label>
|
|
||||||
{uiElement.type === 'boolean' ? (
|
{uiElement.type === 'boolean' ? (
|
||||||
<Switch
|
<Switch
|
||||||
checked={uiElement.value || false}
|
checked={uiElement.value || false}
|
||||||
@@ -148,106 +149,108 @@ export default function NotificationAdapterMutator({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Form.Input
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
field={uiElement.label}
|
||||||
type={uiElement.type}
|
type={uiElement.type}
|
||||||
value={uiElement.value || ''}
|
value={uiElement.value || ''}
|
||||||
placeholder={uiElement.label}
|
placeholder={uiElement.label}
|
||||||
onChange={(e) => {
|
label={uiElement.label}
|
||||||
setValue(selectedAdapter, uiElement, key, e.target.value);
|
onChange={(value) => {
|
||||||
|
setValue(selectedAdapter, uiElement, key, value);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Form.Field>
|
</Form>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
onClose={() => onVisibilityChanged(false)}
|
title="Adding a new Notification Adapter"
|
||||||
onOpen={() => onVisibilityChanged(true)}
|
visible={visible}
|
||||||
open={visible}
|
|
||||||
style={{ width: '95%' }}
|
style={{ width: '95%' }}
|
||||||
|
footer={
|
||||||
|
<div>
|
||||||
|
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={() => onTry()}>
|
||||||
|
Try
|
||||||
|
</Button>
|
||||||
|
<Button type="danger" onClick={() => onSubmit(true)}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => onSubmit(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Modal.Header>Adding a new Notification Adapter</Modal.Header>
|
{validationMessage != null && (
|
||||||
<Modal.Content image>
|
<Banner
|
||||||
<Modal.Description>
|
fullMode={false}
|
||||||
{validationMessage != null && (
|
type="danger"
|
||||||
<Message negative>
|
closeIcon={null}
|
||||||
<Message.Header>Houston we have a problem...</Message.Header>
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||||
<p dangerouslySetInnerHTML={{ __html: validationMessage }} />
|
style={{ marginBottom: '1rem' }}
|
||||||
</Message>
|
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
||||||
)}
|
|
||||||
{successMessage != null && (
|
|
||||||
<Message positive>
|
|
||||||
<Message.Header>Yay!</Message.Header>
|
|
||||||
<p dangerouslySetInnerHTML={{ __html: successMessage }} />
|
|
||||||
</Message>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be
|
|
||||||
configured. <br />
|
|
||||||
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
|
||||||
</p>
|
|
||||||
<Dropdown
|
|
||||||
placeholder="Select a notification adapter"
|
|
||||||
className="providerMutator__fields"
|
|
||||||
selection
|
|
||||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
|
||||||
options={adapter
|
|
||||||
.map((a) => {
|
|
||||||
return {
|
|
||||||
key: a.id,
|
|
||||||
value: a.id,
|
|
||||||
text: a.name,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
//filter out those, that have already been selected
|
|
||||||
.filter((option) =>
|
|
||||||
editNotificationAdapter != null
|
|
||||||
? true
|
|
||||||
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
|
||||||
)
|
|
||||||
.sort(sortAdapter)}
|
|
||||||
onChange={(e, { value }) => {
|
|
||||||
setSuccessMessage(null);
|
|
||||||
setValidationMessage(null);
|
|
||||||
const selectedAdapter = adapter.find((a) => a.id === value);
|
|
||||||
setSelectedAdapter(Object.assign({}, selectedAdapter));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
{selectedAdapter != null && (
|
|
||||||
<Form>
|
|
||||||
<i>{selectedAdapter.description}</i>
|
|
||||||
<br />
|
|
||||||
{selectedAdapter.readme != null && (
|
|
||||||
<React.Fragment>
|
|
||||||
<Help readme={selectedAdapter.readme} />
|
|
||||||
</React.Fragment>
|
|
||||||
)}
|
|
||||||
<br />
|
|
||||||
{getFieldsFor(selectedAdapter)}
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</Modal.Description>
|
|
||||||
</Modal.Content>
|
|
||||||
<Modal.Actions>
|
|
||||||
<Button
|
|
||||||
content="Try Notification Adapter"
|
|
||||||
labelPosition="left"
|
|
||||||
floated="left"
|
|
||||||
icon="hand spock"
|
|
||||||
onClick={() => onTry()}
|
|
||||||
color="teal"
|
|
||||||
/>
|
/>
|
||||||
<Button color="black" onClick={() => onSubmit(false)}>
|
)}
|
||||||
Cancel
|
{successMessage != null && (
|
||||||
</Button>
|
<Banner
|
||||||
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
|
fullMode={false}
|
||||||
</Modal.Actions>
|
type="success"
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Yay!</div>}
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be configured.{' '}
|
||||||
|
<br />
|
||||||
|
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
filter
|
||||||
|
placeholder="Select a notification adapter"
|
||||||
|
className="providerMutator__fields"
|
||||||
|
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||||
|
optionList={adapter
|
||||||
|
.map((a) => {
|
||||||
|
return {
|
||||||
|
otherKey: a.id,
|
||||||
|
value: a.id,
|
||||||
|
label: a.name,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
//filter out those, that have already been selected
|
||||||
|
.filter((option) =>
|
||||||
|
editNotificationAdapter != null
|
||||||
|
? true
|
||||||
|
: selected.find((selectedOption) => selectedOption.id === option.key) == null
|
||||||
|
)
|
||||||
|
.sort(sortAdapter)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSuccessMessage(null);
|
||||||
|
setValidationMessage(null);
|
||||||
|
const selectedAdapter = adapter.find((a) => a.id === value);
|
||||||
|
setSelectedAdapter(Object.assign({}, selectedAdapter));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{selectedAdapter != null && (
|
||||||
|
<>
|
||||||
|
<i>{selectedAdapter.description}</i>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
{selectedAdapter.readme != null && <Help readme={selectedAdapter.readme} />}
|
||||||
|
<br />
|
||||||
|
{getFieldsFor(selectedAdapter)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Accordion, Icon } from 'semantic-ui-react';
|
import { Banner } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
export default function Help({ readme }) {
|
export default function Help({ readme }) {
|
||||||
const [active, setActive] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion>
|
<Banner
|
||||||
<Accordion.Title active={active} index={0} onClick={() => setActive(!active)}>
|
fullMode={false}
|
||||||
<React.Fragment>
|
type="info"
|
||||||
<Icon name="dropdown" /> <span className="providerMutator__helpLink"> More information</span>
|
closeIcon={null}
|
||||||
</React.Fragment>
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Information</div>}
|
||||||
</Accordion.Title>
|
description={<p dangerouslySetInnerHTML={{ __html: readme }} />}
|
||||||
<Accordion.Content active={active} className="providerMutator__helpBox">
|
/>
|
||||||
<p dangerouslySetInnerHTML={{ __html: readme }} />
|
|
||||||
</Accordion.Content>
|
|
||||||
</Accordion>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui';
|
||||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||||
import { Modal, Icon, Button, Dropdown, Input, Message } from 'semantic-ui-react';
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { IconLikeHeart } from '@douyinfe/semi-icons';
|
||||||
import './ProviderMutator.less';
|
import './ProviderMutator.less';
|
||||||
|
|
||||||
const sortProvider = (a, b) => {
|
const sortProvider = (a, b) => {
|
||||||
@@ -61,79 +61,90 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal onClose={() => onVisibilityChanged(false)} onOpen={() => onVisibilityChanged(true)} open={visible}>
|
<Modal
|
||||||
<Modal.Header>Adding a new Provider</Modal.Header>
|
title="Adding a new Provider"
|
||||||
<Modal.Content image>
|
visible={visible}
|
||||||
<Modal.Description>
|
onOk={() => onSubmit(true)}
|
||||||
{validationMessage != null && (
|
onCancel={() => onSubmit(false)}
|
||||||
<Message negative>
|
style={{ width: '50rem' }}
|
||||||
<Message.Header>Houston we have a problem...</Message.Header>
|
okText="Save"
|
||||||
<p>{validationMessage}</p>
|
>
|
||||||
</Message>
|
{validationMessage != null && (
|
||||||
)}
|
<Banner
|
||||||
|
fullMode={false}
|
||||||
|
type="danger"
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Error</div>}
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
description={validationMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
Provider are the <Icon name="heart" color="red" /> of Fredy. We're supporting multiple Provider such as
|
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
|
||||||
Immowelt, Kalaydo etc. Select a provider from the list below.
|
such as Immowelt, Kalaydo etc. Select a provider from the list below.
|
||||||
<br />
|
<br />
|
||||||
Fredy will then open the provider's url in a new tab.
|
Fredy will then open the provider's url in a new tab.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
You will need to configure your search parameter like you would do when you do a regular search on the
|
You will need to configure your search parameter like you would do when you do a regular search on the
|
||||||
provider's website.
|
provider's website.
|
||||||
<br />
|
<br />
|
||||||
When the search results are shown on the website, copy the url and paste it into the textfield below.
|
When the search results are shown on the website, copy the url and paste it into the textfield below.
|
||||||
<br />
|
</p>
|
||||||
<span style={{ color: '#ff0000' }}>
|
<Banner
|
||||||
If you chose Immoscout as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
fullMode={false}
|
||||||
|
type="warning"
|
||||||
|
closeIcon={null}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>ScrapingAnt</div>}
|
||||||
|
style={{ marginBottom: '1rem' }}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>
|
||||||
|
If you chose Immoscout or Immonet as a provider, make sure to also add the scrapingAnt apiKey to the config.json.
|
||||||
(See readme)
|
(See readme)
|
||||||
</span>
|
</p>
|
||||||
<br />
|
<p>
|
||||||
<span style={{ color: '#ff0000' }}>
|
|
||||||
Do not forget to sort the results by date before copying the url to Fredy, so that Fredy always captures
|
Do not forget to sort the results by date before copying the url to Fredy, so that Fredy always captures
|
||||||
the latest search results.
|
the latest search results.
|
||||||
</span>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<Dropdown
|
}
|
||||||
placeholder="Select a provider"
|
/>
|
||||||
className="providerMutator__fields"
|
|
||||||
selection
|
|
||||||
value={selectedProvider == null ? '' : selectedProvider.id}
|
|
||||||
options={provider
|
|
||||||
.map((pro) => {
|
|
||||||
return {
|
|
||||||
key: pro.id,
|
|
||||||
value: pro.id,
|
|
||||||
text: pro.name,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort(sortProvider)}
|
|
||||||
onChange={(e, { value }) => {
|
|
||||||
const selectedProvider = provider.find((pro) => pro.id === value);
|
|
||||||
setSelectedProvider(selectedProvider);
|
|
||||||
|
|
||||||
window.open(selectedProvider.baseUrl);
|
<Select
|
||||||
}}
|
filter
|
||||||
/>
|
placeholder="Select a provider"
|
||||||
<br />
|
className="providerMutator__fields"
|
||||||
<br />
|
optionList={provider
|
||||||
<Input
|
.map((pro) => {
|
||||||
type="text"
|
return {
|
||||||
placeholder="Provider Url"
|
otherKey: pro.id,
|
||||||
width={10}
|
value: pro.id,
|
||||||
className="providerMutator__fields"
|
label: pro.name,
|
||||||
onBlur={(e) => {
|
};
|
||||||
setProviderUrl(e.target.value);
|
})
|
||||||
}}
|
.sort(sortProvider)}
|
||||||
/>
|
style={{ width: 180 }}
|
||||||
</Modal.Description>
|
value={selectedProvider == null ? '' : selectedProvider.id}
|
||||||
</Modal.Content>
|
onChange={(value) => {
|
||||||
<Modal.Actions>
|
const selectedProvider = provider.find((pro) => pro.id === value);
|
||||||
<Button color="black" onClick={() => onSubmit(false)}>
|
setSelectedProvider(selectedProvider);
|
||||||
Cancel
|
|
||||||
</Button>
|
window.open(selectedProvider.baseUrl);
|
||||||
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
|
}}
|
||||||
</Modal.Actions>
|
/>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Provider Url"
|
||||||
|
width={10}
|
||||||
|
className="providerMutator__fields"
|
||||||
|
onBlur={(e) => {
|
||||||
|
setProviderUrl(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Input } from 'semantic-ui-react';
|
|
||||||
|
|
||||||
import cityBackground from '../../assets/city_background.jpg';
|
import cityBackground from '../../assets/city_background.jpg';
|
||||||
import Logo from '../../components/logo/Logo';
|
import Logo from '../../components/logo/Logo';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { Message } from 'semantic-ui-react';
|
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { Input, Button, Banner } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './login.less';
|
import './login.less';
|
||||||
|
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -44,30 +44,41 @@ export default function Login() {
|
|||||||
<Logo />
|
<Logo />
|
||||||
<form>
|
<form>
|
||||||
<div className="login__loginWrapper">
|
<div className="login__loginWrapper">
|
||||||
{error && <Message negative icon="error" content={error} />}
|
{error && <Banner type="danger" closeIcon={null} description={error} />}
|
||||||
<Input
|
<Input
|
||||||
icon="user"
|
size="large"
|
||||||
iconPosition="left"
|
prefix={<IconUser />}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
defaultValue={username}
|
value={username}
|
||||||
|
showClear
|
||||||
style={{ marginTop: error ? '1rem' : '4rem' }}
|
style={{ marginTop: error ? '1rem' : '4rem' }}
|
||||||
autoFocus
|
autofocus
|
||||||
onChange={(e) => setUserName(e.target.value)}
|
onChange={(value) => setUserName(value)}
|
||||||
|
onKeyPress={async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
await tryLogin();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
size="large"
|
||||||
icon="lock"
|
mode="password"
|
||||||
iconPosition="left"
|
prefix={<IconLock />}
|
||||||
defaultValue={password}
|
value={password}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
style={{ marginTop: '2rem' }}
|
style={{ marginTop: '2rem' }}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(value) => setPassword(value)}
|
||||||
|
onKeyPress={async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
await tryLogin();
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button className="ui primary button" style={{ marginTop: '3rem' }} onClick={tryLogin}>
|
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '3rem' }}>
|
||||||
Login
|
Login
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
&__loginWrapper {
|
&__loginWrapper {
|
||||||
border: 1px solid #555050;
|
border: 1px solid #555050;
|
||||||
border-radius: 30px;
|
border-radius: 30px;
|
||||||
height: 25rem;
|
height: 23rem;
|
||||||
width: 30rem;
|
width: 30rem;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background-color: #151313ab;
|
background-color: #151313ab;
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Modal, Header, Icon, Button } from 'semantic-ui-react';
|
import { Modal } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||||
return (
|
return (
|
||||||
<Modal open={true}>
|
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
||||||
<Header icon="warning sign" content="Warning" />
|
<p>Removing this user will also remove all associated jobs.</p>
|
||||||
<Modal.Content>
|
|
||||||
<p>Removing this user will also remove all associated jobs.</p>
|
|
||||||
</Modal.Content>
|
|
||||||
<Modal.Actions>
|
|
||||||
<Button color="red" onClick={() => onCancel()}>
|
|
||||||
<Icon name="remove" /> Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="green" onClick={() => onOk()}>
|
|
||||||
<Icon name="checkmark" /> Remove
|
|
||||||
</Button>
|
|
||||||
</Modal.Actions>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ToastContext from '../../components/toasts/ToastContext';
|
import { Toast } from '@douyinfe/semi-ui';
|
||||||
import UserTable from '../../components/table/UserTable';
|
import UserTable from '../../components/table/UserTable';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { Button, Icon } from 'semantic-ui-react';
|
import { IconPlus } from '@douyinfe/semi-icons';
|
||||||
|
import { Button } from '@douyinfe/semi-ui';
|
||||||
import UserRemovalModal from './UserRemovalModal';
|
import UserRemovalModal from './UserRemovalModal';
|
||||||
import { xhrDelete } from '../../services/xhr';
|
import { xhrDelete } from '../../services/xhr';
|
||||||
import { useHistory } from 'react-router';
|
import { useHistory } from 'react-router';
|
||||||
@@ -14,7 +15,6 @@ const Users = function Users() {
|
|||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const users = useSelector((state) => state.user.users);
|
const users = useSelector((state) => state.user.users);
|
||||||
const ctx = React.useContext(ToastContext);
|
|
||||||
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
|
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
@@ -23,30 +23,19 @@ const Users = function Users() {
|
|||||||
await dispatch.user.getUsers();
|
await dispatch.user.getUsers();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onUserRemoval = async () => {
|
const onUserRemoval = async () => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||||
ctx.showToast({
|
Toast.success('User successfully remove');
|
||||||
title: 'Success',
|
|
||||||
message: 'User successfully remove',
|
|
||||||
delay: 4000,
|
|
||||||
backgroundColor: '#87eb8f',
|
|
||||||
color: '#000',
|
|
||||||
});
|
|
||||||
setUserIdToBeRemoved(null);
|
setUserIdToBeRemoved(null);
|
||||||
await dispatch.jobs.getJobs();
|
await dispatch.jobs.getJobs();
|
||||||
await dispatch.user.getUsers();
|
await dispatch.user.getUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.showToast({
|
Toast.error(error);
|
||||||
title: 'Error',
|
|
||||||
message: error,
|
|
||||||
delay: 8000,
|
|
||||||
backgroundColor: '#db2828',
|
|
||||||
color: '#fff',
|
|
||||||
});
|
|
||||||
setUserIdToBeRemoved(null);
|
setUserIdToBeRemoved(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -57,8 +46,12 @@ const Users = function Users() {
|
|||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
|
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
|
||||||
|
|
||||||
<Button primary className="users__newButton" onClick={() => history.push('/users/new')}>
|
<Button
|
||||||
<Icon name="plus" />
|
type="primary"
|
||||||
|
className="users__newButton"
|
||||||
|
icon={<IconPlus />}
|
||||||
|
onClick={() => history.push('/users/new')}
|
||||||
|
>
|
||||||
Create new User
|
Create new User
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ToastContext from '../../../components/toasts/ToastContext';
|
|
||||||
import { xhrGet, xhrPost } from '../../../services/xhr';
|
import { xhrGet, xhrPost } from '../../../services/xhr';
|
||||||
import { useHistory, useParams } from 'react-router';
|
import { useHistory, useParams } from 'react-router';
|
||||||
import { Button, Form } from 'semantic-ui-react';
|
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import Switch from 'react-switch';
|
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
import './UserMutator.less';
|
import './UserMutator.less';
|
||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
|
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
const UserMutator = function UserMutator() {
|
const UserMutator = function UserMutator() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -18,7 +16,6 @@ const UserMutator = function UserMutator() {
|
|||||||
const [isAdmin, setIsAdmin] = React.useState(false);
|
const [isAdmin, setIsAdmin] = React.useState(false);
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const ctx = React.useContext(ToastContext);
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -38,6 +35,7 @@ const UserMutator = function UserMutator() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [params.userId]);
|
}, [params.userId]);
|
||||||
|
|
||||||
@@ -51,76 +49,62 @@ const UserMutator = function UserMutator() {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
});
|
});
|
||||||
await dispatch.user.getUsers();
|
await dispatch.user.getUsers();
|
||||||
ctx.showToast({
|
Toast.success('User successfully saved...');
|
||||||
title: 'Success',
|
|
||||||
message: 'User successfully saved...',
|
|
||||||
delay: 5000,
|
|
||||||
backgroundColor: '#87eb8f',
|
|
||||||
color: '#000',
|
|
||||||
});
|
|
||||||
history.push('/users');
|
history.push('/users');
|
||||||
} catch (Exception) {
|
} catch (error) {
|
||||||
console.error(Exception);
|
console.error(error);
|
||||||
ctx.showToast({
|
Toast.error(error.json.message);
|
||||||
title: 'Error',
|
|
||||||
message: Exception.json.message,
|
|
||||||
delay: 6000,
|
|
||||||
backgroundColor: '#db2828',
|
|
||||||
color: '#fff',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form inverted className="userMutator">
|
<form className="userMutator">
|
||||||
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
||||||
<Form.Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
label="Username"
|
label="Username"
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
placeholder="Username"
|
placeholder="Username"
|
||||||
autoFocus
|
autoFocus
|
||||||
inverted
|
|
||||||
width={6}
|
width={6}
|
||||||
defaultValue={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(val) => setUsername(val)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
||||||
<Form.Input
|
<Input
|
||||||
type="password"
|
mode="password"
|
||||||
label="Password"
|
label="Password"
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
inverted
|
|
||||||
width={6}
|
width={6}
|
||||||
defaultValue={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(val) => setPassword(val)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
||||||
<Form.Input
|
<Input
|
||||||
type="password"
|
mode="password"
|
||||||
label="Retype password"
|
label="Retype password"
|
||||||
placeholder="Retype password"
|
placeholder="Retype password"
|
||||||
inverted
|
|
||||||
width={6}
|
width={6}
|
||||||
defaultValue={password2}
|
value={password2}
|
||||||
onChange={(e) => setPassword2(e.target.value)}
|
onChange={(val) => setPassword2(val)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<SegmentPart name="Admin use" helpText="Check this if the user is an administrator">
|
<Divider margin="1rem" />
|
||||||
<Form.Field>
|
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
|
||||||
<label>Is user an admin?</label>
|
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
|
||||||
</Form.Field>
|
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Button color="red" onClick={() => history.push('/users')}>
|
<Divider margin="1rem" />
|
||||||
|
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/users')}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="green" onClick={saveUser}>
|
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user