Reupload
This commit is contained in:
commit
61cbd57af1
168 changed files with 31208 additions and 0 deletions
2
web-app/.env.development
Normal file
2
web-app/.env.development
Normal file
|
@ -0,0 +1,2 @@
|
|||
REACT_APP_API_URL=http://localhost:8080
|
||||
REACT_APP_DOCS_URL=http://localhost:8000
|
4
web-app/.env.production
Normal file
4
web-app/.env.production
Normal file
|
@ -0,0 +1,4 @@
|
|||
REACT_APP_API_URL=http://localhost:8080
|
||||
REACT_APP_DOCS_URL=https://docs.awary.app
|
||||
|
||||
DOCKER_SERVER_PORT=3000
|
17587
web-app/package-lock.json
generated
Normal file
17587
web-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
56
web-app/package.json
Normal file
56
web-app/package.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "front",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.11.45",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/recharts": "^1.8.24",
|
||||
"moment": "^2.29.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.33.1",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"recharts": "^2.3.2",
|
||||
"typescript": "^4.7.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject",
|
||||
"docker:serve": "npx env-cmd -f .env.production sh docker-serve.sh"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.7",
|
||||
"postcss": "^8.4.14",
|
||||
"serve": "^14.0.1",
|
||||
"tailwindcss": "^3.1.6"
|
||||
}
|
||||
}
|
6
web-app/postcss.config.js
Normal file
6
web-app/postcss.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
web-app/public/awary_logo.png
Normal file
BIN
web-app/public/awary_logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
43
web-app/public/index.html
Normal file
43
web-app/public/index.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/awary_logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Simple, open-source, events logging via HTTP"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/awary_logo.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<!--<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />-->
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Awary</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
25
web-app/public/manifest.json
Normal file
25
web-app/public/manifest.json
Normal file
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
3
web-app/public/robots.txt
Normal file
3
web-app/public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
38
web-app/src/App.css
Normal file
38
web-app/src/App.css
Normal file
|
@ -0,0 +1,38 @@
|
|||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
9
web-app/src/App.test.tsx
Normal file
9
web-app/src/App.test.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
45
web-app/src/App.tsx
Normal file
45
web-app/src/App.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import React, {useState} from 'react';
|
||||
import './App.css';
|
||||
import {Api, useApi} from './api';
|
||||
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom';
|
||||
import LoginPage from './pages/login';
|
||||
import SignupPage from './pages/Signup';
|
||||
import Projects from './pages/projects';
|
||||
import {ModalServiceProvider, useModal} from './services/ModalService';
|
||||
import ProjectPage from './pages/projectPage';
|
||||
import HeaderBar from './components/HeaderBar';
|
||||
import ProjectLogsPage from './pages/ProjectLogs';
|
||||
import {ProjectApiKeysPage} from './pages/ProjectApiKeys';
|
||||
import {RequireAuth} from './components/RequireAuth';
|
||||
import ProjectPageBase from './components/ProjectPageBase';
|
||||
|
||||
function App() {
|
||||
console.log(`App started on env '${process.env.NODE_ENV}'`)
|
||||
console.log(`API url is ${process.env.REACT_APP_API_URL}`)
|
||||
return (
|
||||
<div className="App bg-neutral-700 text-white h-screen overflow-hidden f-c scroll-smooth">
|
||||
<BrowserRouter>
|
||||
<Api>
|
||||
<ModalServiceProvider>
|
||||
<div className="w-full h-full">
|
||||
<Routes>
|
||||
<Route path="/signup" element={<SignupPage/>}/>
|
||||
<Route path="/" element={<LoginPage/>}/>
|
||||
<Route path="/projects" element={
|
||||
<RequireAuth>
|
||||
<Projects/>
|
||||
</RequireAuth>
|
||||
}/>
|
||||
<Route path="/projects/:projectId/*" element={
|
||||
<ProjectPageBase/>
|
||||
}/>
|
||||
</Routes>
|
||||
</div>
|
||||
</ModalServiceProvider>
|
||||
</Api>
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
305
web-app/src/api/index.tsx
Normal file
305
web-app/src/api/index.tsx
Normal file
|
@ -0,0 +1,305 @@
|
|||
import React, {createContext, useContext} from "react";
|
||||
import {ApiKey, ApiKeyData} from "../core/ApiKey";
|
||||
import {Log, LogData} from "../core/Log";
|
||||
import {Metric, MetricData, MetricDataOnUpdate} from "../core/Metric";
|
||||
import {Project, ProjectData} from "../core/Project";
|
||||
import { Tag, TagData } from "../core/Tag";
|
||||
import {View, ViewDataOnCreation, ViewDataOnUpdate} from "../core/View";
|
||||
import {DashboardView} from "../pages/projectPage/forms/OrganizeDashboardForm";
|
||||
|
||||
export interface UserData {
|
||||
email: string,
|
||||
token: string,
|
||||
_id: string
|
||||
}
|
||||
|
||||
type MyProps = {
|
||||
children: any
|
||||
}
|
||||
|
||||
type MyState = {
|
||||
logged: boolean
|
||||
loading: boolean
|
||||
token: string | null
|
||||
userData: UserData | null
|
||||
}
|
||||
|
||||
export class Api extends React.Component<MyProps, MyState> {
|
||||
|
||||
private _baseUrl = process.env.REACT_APP_API_URL
|
||||
|
||||
state: MyState = {
|
||||
logged: false,
|
||||
loading: true,
|
||||
token: localStorage.getItem("token"),
|
||||
userData: null
|
||||
}
|
||||
|
||||
/* eslint-disable-next-line */
|
||||
constructor(props: MyProps) {
|
||||
super(props)
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
if (!this.state.token)
|
||||
return;
|
||||
try {
|
||||
const userData = await this._FetchCurrentAuthentifiedUser()
|
||||
if (userData && userData.email) {
|
||||
console.log(`Connected with email ${userData.email}`)
|
||||
this.setState({userData: userData, loading: false})
|
||||
} else {
|
||||
await this.Logout();
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({userData: null, loading: false})
|
||||
console.error("Something wrong happened")
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private async _Get(path: string) {
|
||||
const response = await fetch(`${this._baseUrl}${path}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.state.token}`,
|
||||
"Content-type": "application/json"
|
||||
}
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
|
||||
private async _Delete(path: string) {
|
||||
await fetch(`${this._baseUrl}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.state.token}`,
|
||||
"Content-type": "application/json"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async _Post(path: string, body: Object) {
|
||||
const response = await fetch(`${this._baseUrl}${path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.state.token}`,
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
if (response.status < 200 || response.status > 299)
|
||||
throw response
|
||||
return response.json()
|
||||
}
|
||||
|
||||
private async _Put(path: string, body: Object) {
|
||||
const response = await fetch(`${this._baseUrl}${path}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.state.token}`,
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
if (response.status < 200 || response.status > 299)
|
||||
throw response
|
||||
return response.json()
|
||||
}
|
||||
|
||||
public async Login(email: string, password: string): Promise<UserData> {
|
||||
const response = await fetch(`${this._baseUrl}/login`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password
|
||||
})
|
||||
})
|
||||
const userData = (await response.json()) as unknown as UserData
|
||||
|
||||
|
||||
|
||||
if (response.status === 401) {
|
||||
this.setState({logged: false, token: null, userData: null})
|
||||
throw Error("Wrong email or password")
|
||||
} else if (response.status !== 200) {
|
||||
this.setState({logged: false, token: null, userData: null})
|
||||
throw Error("Log in error")
|
||||
}
|
||||
|
||||
localStorage.setItem("token", userData.token);
|
||||
this.setState({logged: true, userData: userData, token: userData.token})
|
||||
return userData
|
||||
}
|
||||
|
||||
public async isUserRegistrationEnabled(): Promise<boolean> {
|
||||
const response = await this._Get("/isUserRegistrationEnabled") as {enabled: boolean};
|
||||
return response.enabled;
|
||||
}
|
||||
|
||||
public async Signup(email: string, password: string, code: string): Promise<UserData> {
|
||||
const response = await fetch(`${this._baseUrl}/signup`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
password,
|
||||
code
|
||||
})
|
||||
})
|
||||
const userData = (await response.json()) as any
|
||||
|
||||
if (response.status !== 201) {
|
||||
this.setState({logged: false, token: null, userData: null})
|
||||
throw Error(userData?.message|| "Sign up error")
|
||||
}
|
||||
|
||||
return userData
|
||||
}
|
||||
public async Logout(): Promise<void> {
|
||||
this.setState({token: null, userData: null, loading: false})
|
||||
localStorage.removeItem("token")
|
||||
}
|
||||
|
||||
private async _FetchCurrentAuthentifiedUser(): Promise<UserData> {
|
||||
return this._Get("/auth") as unknown as UserData
|
||||
}
|
||||
|
||||
public async VerifyEmail(email: string, emailConfirmationToken: string): Promise<boolean> {
|
||||
try {
|
||||
await this._Get(`/signup-email-confirmation?email=${email}&emailConfirmationToken=${emailConfirmationToken}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchProjects(): Promise<Project[]> {
|
||||
const projectsData = await this._Get("/projects") as ProjectData[];
|
||||
return projectsData.map(data => new Project(data));
|
||||
}
|
||||
|
||||
public async fetchProject(id: string): Promise<Project> {
|
||||
const projectData = await this._Get(`/projects/${id}`) as ProjectData;
|
||||
return new Project(projectData);
|
||||
}
|
||||
|
||||
public async fetchProjectLogs(id: string): Promise<Log[]> {
|
||||
const logsData = await this._Get(`/projects/${id}/logs`) as LogData[];
|
||||
return logsData.map(data => new Log(data));
|
||||
}
|
||||
|
||||
public async fetchProjectTags(id: string): Promise<Tag[]> {
|
||||
const tagsData = await this._Get(`/projects/${id}/tags`) as TagData[];
|
||||
return tagsData.map(data => new Tag(data));
|
||||
}
|
||||
|
||||
public async deleteLog(projectId: string, logId: string): Promise<void> {
|
||||
await this._Delete(`/projects/${projectId}/logs/${logId}`);
|
||||
}
|
||||
|
||||
|
||||
public async fetchProjectMetrics(id: string): Promise<Metric[]> {
|
||||
const metricsData = await this._Get(`/projects/${id}/metrics`) as MetricData[];
|
||||
return metricsData.map(data => new Metric(data));
|
||||
}
|
||||
|
||||
public async fetchProjectMetric(projectId: string, metricId: string): Promise<Metric> {
|
||||
const data = await this._Get(`/projects/${projectId}/metrics/${metricId}`) as MetricData;
|
||||
return new Metric(data);
|
||||
}
|
||||
|
||||
async createSeries(projectId: string, data: MetricDataOnUpdate): Promise<void> {
|
||||
await this._Post(`/projects/${projectId}/metrics`, data)
|
||||
}
|
||||
|
||||
async updateMetric(projectId: string, metricId: string, data: MetricDataOnUpdate): Promise<void> {
|
||||
await this._Put(`/projects/${projectId}/metrics/${metricId}`, data)
|
||||
}
|
||||
|
||||
async addValueToSerie(projectId: string, seriesId: string, value: number): Promise<void> {
|
||||
await this._Post(`/projects/${projectId}/metrics/${seriesId}`, {value})
|
||||
}
|
||||
|
||||
async deleteMetric(projectId: string, metricId: string): Promise<void> {
|
||||
await this._Delete(`/projects/${projectId}/metrics/${metricId}`)
|
||||
}
|
||||
|
||||
async deleteMetricHistory(projectId: string, metricId: string, recordId: string): Promise<void> {
|
||||
await this._Delete(`/projects/${projectId}/metrics/${metricId}/history/${recordId}`)
|
||||
}
|
||||
|
||||
async createView(projectId: string, data: ViewDataOnCreation<any>): Promise<void> {
|
||||
await this._Post(`/projects/${projectId}/views`, {...data, config: JSON.stringify(data.config)})
|
||||
}
|
||||
|
||||
async updateView(projectId: string, view: View<any>, data: ViewDataOnUpdate<any>): Promise<void> {
|
||||
await this._Put(`/projects/${projectId}/views/${view.id}`, {...data, config: JSON.stringify(data.config)})
|
||||
}
|
||||
|
||||
public async fetchDashboardView(projectId: string): Promise<View<DashboardView> | null> {
|
||||
const viewsData = await this._Get(`/projects/${projectId}/views`) as View<DashboardView>[];
|
||||
if (viewsData.length === 0) {
|
||||
return null
|
||||
}
|
||||
return new View({...viewsData[0], config: JSON.parse(viewsData[0].config as unknown as string)});
|
||||
}
|
||||
|
||||
public async fetchProjectApiKeys(id: string): Promise<ApiKey[]> {
|
||||
const apiKeysData = await this._Get(`/projects/${id}/apiKeys`) as ApiKeyData[];
|
||||
return apiKeysData.map(data => new ApiKey(data));
|
||||
}
|
||||
|
||||
public async createProject(data: {name: string}): Promise<void> {
|
||||
return this._Post("/projects", data);
|
||||
}
|
||||
|
||||
public async generateApiKey(projectId: string, data: {name: string}): Promise<void> {
|
||||
return this._Post(`/projects/${projectId}/apiKeys`, data);
|
||||
}
|
||||
|
||||
public async deleteApiKey(projectId: string, apiKeyId: string): Promise<void> {
|
||||
return this._Delete(`/projects/${projectId}/apiKeys/${apiKeyId}`);
|
||||
}
|
||||
|
||||
public async createTag(projectId: string, data: {name: string, color: string}): Promise<void> {
|
||||
return this._Post(`/projects/${projectId}/tags`, data);
|
||||
}
|
||||
|
||||
public async updateTag(projectId: string, tagId: string, data: {name: string, color: string}): Promise<void> {
|
||||
return this._Put(`/projects/${projectId}/tags/${tagId}`, data);
|
||||
}
|
||||
|
||||
public async deleteTag(projectId: string, tagId: string): Promise<void> {
|
||||
return this._Delete(`/projects/${projectId}/tags/${tagId}`);
|
||||
}
|
||||
|
||||
public IsLogged(): boolean {
|
||||
return this.state.token !== null
|
||||
}
|
||||
|
||||
public GetUserData(): UserData {
|
||||
return this.state.userData as UserData
|
||||
|
||||
}
|
||||
|
||||
public IsLoadingUser(): boolean {
|
||||
return this.state.loading;
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<ApiContext.Provider value={{api: this, logged: this.GetUserData() !== null}}>
|
||||
{this.props.children}
|
||||
</ApiContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const ApiContext = createContext<{api: Api, logged: boolean}>({logged: false} as {api: Api, logged: boolean})
|
||||
export const useApi = () => useContext(ApiContext).api
|
3
web-app/src/components/AppName.tsx
Normal file
3
web-app/src/components/AppName.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export default function AppName() {
|
||||
return <span className="text-4xl"><span className="text-app-primary">Awary</span></span>
|
||||
}
|
41
web-app/src/components/Button.tsx
Normal file
41
web-app/src/components/Button.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {ButtonHTMLAttributes, useState} from "react";
|
||||
import Spinner from "./Spinner";
|
||||
|
||||
const buttons: {[index: string]: string} = {
|
||||
primary: `inline-block px-6 py-2.5 bg-primary text-white font-medium text-xs leading-tight uppercase rounded hover:bg-primary-hover focus:bg-primary focus:outline-none focus:ring-0 active:bg-primary-active transition duration-150 ease-in-out`,
|
||||
danger: "inline-block px-6 py-2.5 bg-red-600 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-red-700 focus:bg-red-700 focus:outline-none focus:ring-0 active:bg-red-800 transition duration-150 ease-in-out",
|
||||
light: "inline-block px-6 py-2.5 bg-gray-200 text-gray-700 font-medium text-xs leading-tight uppercase rounded hover:bg-gray-300 focus:bg-gray-300 focus:outline-none focus:ring-0 active:bg-gray-400 transition duration-150 ease-in-out",
|
||||
dark: "inline-block px-6 py-2.5 bg-neutral-800 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-neutral-900 focus:bg-neutral-900 focus:outline-none focus:ring-0 active:bg-neutral-900 transition duration-150 ease-in-out",
|
||||
"brighter-dark": "inline-block px-6 py-2.5 bg-neutral-700 text-white font-medium text-xs leading-tight uppercase rounded hover:bg-neutral-900 focus:bg-neutral-900 focus:outline-none focus:ring-0 active:bg-neutral-900 transition duration-150 ease-in-out",
|
||||
}
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
text: string
|
||||
color?: string
|
||||
onClickAsync?: () => Promise<void>
|
||||
spinner?: boolean
|
||||
}
|
||||
|
||||
export default function Button({text, color, className, onClickAsync, spinner, ...props}: ButtonProps) {
|
||||
const colorKey = color ? color : "primary"
|
||||
const buttonStyle = buttons[colorKey] ? buttons[colorKey] : buttons.primary
|
||||
const [showSpinner, SetShowSnipper] = useState(false)
|
||||
|
||||
let onClick = props.onClick
|
||||
|
||||
if (onClickAsync) {
|
||||
onClick = async () => {
|
||||
SetShowSnipper(true)
|
||||
try {
|
||||
await onClickAsync()
|
||||
} catch (e) {console.error(e)}
|
||||
SetShowSnipper(false)
|
||||
}
|
||||
}
|
||||
|
||||
const shouldShowSpinner = spinner || showSpinner
|
||||
|
||||
return (
|
||||
<button {...props} onClick={onClick} className={`${buttonStyle} w-[fit-content] font-bold flex flex-row items-center gap-4 ${className}`} disabled={shouldShowSpinner}>{text} {shouldShowSpinner && <Spinner/>}</button>
|
||||
)
|
||||
}
|
26
web-app/src/components/Card.tsx
Normal file
26
web-app/src/components/Card.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {HTMLAttributes, ReactElement} from "react";
|
||||
|
||||
interface CardProps extends HTMLAttributes<HTMLElement> {
|
||||
header?: string | ReactElement<any, any>
|
||||
hideSeparator?: boolean
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export default function Card({header, hideSeparator, children, onClick, className}: CardProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-neutral-600 rounded-md text-left p-4 ${className}`}
|
||||
onClick={() => {onClick?.()}}
|
||||
>
|
||||
{header && <>
|
||||
<div className="text-xl bg-neutral-600">{header}</div>
|
||||
{!hideSeparator && <div className="border-b border-b-neutral-500 my-4"/>}
|
||||
</>}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
98
web-app/src/components/Chart.tsx
Normal file
98
web-app/src/components/Chart.tsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import moment from "moment";
|
||||
import { useState } from "react";
|
||||
import {Bar, ComposedChart} from "recharts";
|
||||
import {Line, ResponsiveContainer, Tooltip, XAxis, YAxis} from "recharts";
|
||||
import {formatTimestamp} from "../utils/formatTimestamp";
|
||||
import Select from "./Select";
|
||||
|
||||
const dayDuration = 1000 * 60 * 60 * 24 // Duration of a day in milliseconds
|
||||
|
||||
function lastDays(data: {value: number | null, date: number}[], alwaysUseLastValue: boolean, daysCount: number = 30) {
|
||||
data.sort((a, b) => b.date - a.date)
|
||||
const processedData: {date: string, value: number | null}[] = []
|
||||
const startingPoint = moment(Date.now()).endOf("day").valueOf()
|
||||
|
||||
for (let currentDay = 0; currentDay < daysCount; ++currentDay) {
|
||||
const beforeThreshold = startingPoint - (currentDay * dayDuration)
|
||||
let last = data.find(x => x.date < beforeThreshold)
|
||||
if (!alwaysUseLastValue && last && last?.date < beforeThreshold - (dayDuration)) {
|
||||
last = undefined
|
||||
}
|
||||
processedData.push({
|
||||
date: formatTimestamp(beforeThreshold),
|
||||
value: last && last.value !== null ? last.value : null
|
||||
})
|
||||
}
|
||||
|
||||
return processedData.reverse()
|
||||
}
|
||||
|
||||
type ChartProps = {
|
||||
charts: {
|
||||
name: string
|
||||
type: string
|
||||
color: string
|
||||
alwaysUseLastValue: boolean
|
||||
data: {
|
||||
value: number
|
||||
date: number
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function Chart({charts}: ChartProps) {
|
||||
|
||||
const [daysRange, setDaysRange] = useState<number>(30)
|
||||
const processedData = charts.map(d => ({
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
color: d.color,
|
||||
data: lastDays(d.data, d.alwaysUseLastValue, daysRange)
|
||||
}))
|
||||
const chartData: {
|
||||
values: number[]
|
||||
date: string
|
||||
}[] = []
|
||||
|
||||
for (let i = 0; i < processedData.length; ++i) {
|
||||
for (let j = 0; j < daysRange; ++j) {
|
||||
if (i === 0) {
|
||||
chartData.push({date: processedData[i].data[j].date, values: [processedData[i].data[j].value || 0]})
|
||||
continue;
|
||||
}
|
||||
chartData[j].values.push(processedData[i].data[j].value || 0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2">
|
||||
<div className="f-r items-center gap-2">
|
||||
<span>Show:</span>
|
||||
<Select onChange={(event) => setDaysRange(parseInt(event.target.value))}>
|
||||
<option value={7}>last 7 days</option>
|
||||
<option value={15}>last 15 days</option>
|
||||
<option selected value={30}>last 30 days</option>
|
||||
<option value={60}>last 60 days</option>
|
||||
<option value={90}>last 90 days</option>
|
||||
<option value={360}>last 365 days</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-b border-b-neutral-500 my-4"/>
|
||||
<ResponsiveContainer width="99%" height={180}>
|
||||
<ComposedChart data={chartData}>
|
||||
{chartData.map((_, index) => {
|
||||
if (charts[index]?.type === "line")
|
||||
return <Line type="monotone" dataKey={`values[${index}]`} dot={false} name={charts[index]?.name} stroke={charts[index].color} />
|
||||
if (charts[index]?.type === "bar")
|
||||
return <Bar fill={charts[index]?.color} dataKey={`values[${index}]`} name={charts[index]?.name} stroke={charts[index].color} />
|
||||
})}
|
||||
<XAxis dataKey="date" stroke="#AAAAAA"/>
|
||||
<YAxis stroke="#AAAAAA"/>
|
||||
<Tooltip contentStyle={{backgroundColor: "#444444"}}/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</>
|
||||
)
|
||||
}
|
44
web-app/src/components/HeaderBar.tsx
Normal file
44
web-app/src/components/HeaderBar.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import {useState} from "react";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import AppName from "./AppName";
|
||||
import {MenuList, MenuListItem} from "./MenuList";
|
||||
|
||||
export default function HeaderBar() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 py-4 f-r justify-between items-center gap-2 px-0 xl:px-96">
|
||||
<div className="f-r flex-1 justify-start">
|
||||
<Link to={api.IsLogged() ? "/projects" : "/"}>
|
||||
<AppName/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex-1 f-r items-center justify-end">
|
||||
{
|
||||
api.GetUserData() ?
|
||||
<>
|
||||
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
|
||||
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
|
||||
{api.GetUserData().email}
|
||||
</div>
|
||||
<MenuList show={showMenu}>
|
||||
<MenuListItem text="Logout" onClick={async () => {
|
||||
await api.Logout();
|
||||
navigate("/")
|
||||
}}/>
|
||||
</MenuList>
|
||||
</div>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
49
web-app/src/components/Input.tsx
Normal file
49
web-app/src/components/Input.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import {InputHTMLAttributes, TextareaHTMLAttributes} from "react"
|
||||
import {UseFormRegisterReturn} from "react-hook-form"
|
||||
|
||||
interface LineEditProps {
|
||||
state?: string,
|
||||
stateHandler?: (value: string) => void,
|
||||
register?: UseFormRegisterReturn,
|
||||
label?: string,
|
||||
labelError?: string,
|
||||
isValid?: (value: string) => boolean
|
||||
value?: string | number
|
||||
onChange?: (value: string) => void
|
||||
required?: boolean
|
||||
type?: string
|
||||
minLength?: number
|
||||
multiline?: boolean
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
rows?: number
|
||||
cols?: number
|
||||
}
|
||||
|
||||
export default function LineEdit({state, stateHandler, label, labelError, isValid, register, value, ...props}: LineEditProps) {
|
||||
|
||||
const inputProps: InputHTMLAttributes<HTMLInputElement> & TextareaHTMLAttributes<HTMLTextAreaElement> = {
|
||||
className: `bg-neutral-700 text-white rounded-md p-2 flex-1 min-w-0 ${props.inputClassName}`,
|
||||
onChange: ((e: any) => props.onChange && props.onChange(e.target.value)),
|
||||
required: props.required,
|
||||
type: props.type,
|
||||
minLength: props.minLength,
|
||||
rows: props.rows,
|
||||
cols: props.cols
|
||||
}
|
||||
|
||||
if (!register) {
|
||||
inputProps.value = value
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-left min-w-0 ${props.className}`}>
|
||||
{label && <div className="text-sm mb-1 ml-1">{label}</div>}
|
||||
<div className="f-r">
|
||||
{!props.multiline && <input {...inputProps} {...register}/>}
|
||||
{props.multiline && <textarea {...inputProps} {...register}/>}
|
||||
</div>
|
||||
{labelError && <div className="text-red-300">{labelError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
69
web-app/src/components/LogCard.tsx
Normal file
69
web-app/src/components/LogCard.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import {TrashIcon} from "@heroicons/react/outline";
|
||||
import moment from "moment";
|
||||
import {useState} from "react";
|
||||
import {useApi} from "../api";
|
||||
import {Log} from "../core/Log";
|
||||
import {Project} from "../core/Project";
|
||||
import {TagSticker} from "./TagSticker";
|
||||
import {useModal} from "../services/ModalService";
|
||||
import {formatTimestamp} from "../utils/formatTimestamp";
|
||||
|
||||
export type LogCardProps = {
|
||||
project: Project
|
||||
log: Log
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export function LogCard({log, project, onDelete}: LogCardProps) {
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
const modalService = useModal()
|
||||
const api = useApi()
|
||||
|
||||
const deleteLog = () => {
|
||||
modalService.confirmation("Delete the log ?", async () => {
|
||||
await api.deleteLog(log.projectId, log.id)
|
||||
onDelete && onDelete();
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card p-4 cursor-pointer" onClick={() => {setShowContent(true)}}>
|
||||
<div className="f-c gap-2" >
|
||||
<div className="f-r gap-4 items-center">
|
||||
<span className="text-xl">{log.title}</span>
|
||||
<div className="f-r gap-2">
|
||||
{log.tags.map(tag => <TagSticker tag={tag}/>)}
|
||||
</div>
|
||||
<span className="text-neutral-400 text-sm ml-auto">
|
||||
{formatTimestamp(log.createdAt)}
|
||||
</span>
|
||||
<TrashIcon
|
||||
className="w-4 h-4 text-red-300 cursor-pointer"
|
||||
onClick={deleteLog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showContent && <p className="text-sm text-left pt-4">{log.content}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TinyLogCard({log, project}: LogCardProps) {
|
||||
|
||||
let borderStyle = {}
|
||||
if (log.tags.length > 0) {
|
||||
borderStyle = {borderLeftColor: `${log.tags[0].color}`}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-c gap-2">
|
||||
<div className="f-r items-stretch">
|
||||
<div className="border-l-2 border-neutral-500 pr-2" style={borderStyle}/>
|
||||
<p>{log.title}</p>
|
||||
<p className='mt-auto ml-auto text-xs text-neutral-400'>{moment(log.createdAt).format("YYYY-MM-DD hh:mm a")}</p>
|
||||
</div>
|
||||
<div className="w-full border-b border-neutral-500"/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
25
web-app/src/components/MenuList.tsx
Normal file
25
web-app/src/components/MenuList.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import {HTMLAttributes} from "react";
|
||||
|
||||
export function MenuListItem(props: (HTMLAttributes<HTMLDivElement> & {text: string})) {
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`text-left p-4 hover:bg-neutral-700 cursor-pointer rounded-md ${props.className}`}
|
||||
>
|
||||
{props.text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function MenuList({show, ...props}: (HTMLAttributes<HTMLDivElement> & {show: boolean})) {
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
className={`bg-neutral-900 absolute top-0 left-0 translate-y-[-110%] right-0 w-full rounded-md ${show ? "block" : "hidden"} ${props.className}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
42
web-app/src/components/Modal.tsx
Normal file
42
web-app/src/components/Modal.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import {useEffect, useState} from "react"
|
||||
|
||||
interface ModalProps {
|
||||
show: boolean
|
||||
children?: JSX.Element | null
|
||||
onClose: () => void
|
||||
customId?: string
|
||||
ignoreInputs?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({show, onClose, children, customId, ignoreInputs}: ModalProps) {
|
||||
|
||||
const [createdAt] = useState(Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (!show)
|
||||
return;
|
||||
function handleClose(e: any) {
|
||||
if (!ignoreInputs && !document.getElementById(customId || 'modal-content')?.contains(e.target) && Date.now() - createdAt > 1000) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', handleClose)
|
||||
document.body.style.overflow = "hidden"
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClose)
|
||||
document.body.style.overflow = "visible"
|
||||
}
|
||||
}, [show, onClose, customId, createdAt])
|
||||
|
||||
if (!show) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-0 left-0 w-screen h-screen bg-black bg-opacity-50 flex justify-center items-center overflow-scroll">
|
||||
<div id={customId || 'modal-content'} className="bg-neutral-600 p-2 rounded-md max-h-[90%] overflow-scroll">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
30
web-app/src/components/ProjectHeader.tsx
Normal file
30
web-app/src/components/ProjectHeader.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { ClipboardCopyIcon } from "@heroicons/react/outline";
|
||||
import { Project } from "../core/Project";
|
||||
import { useModal } from "../services/ModalService";
|
||||
import { copyToClipboard } from "../utils/copyToClipboard";
|
||||
|
||||
type ProjectHeaderProps = {
|
||||
project: Project
|
||||
middle?: JSX.Element
|
||||
right?: JSX.Element
|
||||
}
|
||||
|
||||
export default function ProjectHeader({project, middle, right}: ProjectHeaderProps) {
|
||||
const modalService = useModal();
|
||||
return (
|
||||
<div className="f-r pb-4 border-b border-b-neutral-500">
|
||||
<div className="flex-1 f-r items-center gap-2 text-left text-2xl">
|
||||
<h2 className="text-3xl">{project.name}</h2>
|
||||
<ClipboardCopyIcon
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(project.id)
|
||||
modalService.info("Project id copied to clipboard")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 f-c justify-center">{middle}</div>
|
||||
<div className="flex-1 f-c justify-center">{right}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
53
web-app/src/components/ProjectPageBase.tsx
Normal file
53
web-app/src/components/ProjectPageBase.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
import {useCallback, useEffect, useState} from "react";
|
||||
import {Route, Routes, useParams} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import {Project} from "../core/Project";
|
||||
import { MetricHistoryPage } from "../pages/MetricHistory";
|
||||
import {ProjectApiKeysPage} from "../pages/ProjectApiKeys";
|
||||
import ProjectLogsPage from "../pages/ProjectLogs";
|
||||
import {ProjectMetricsPage} from "../pages/ProjectMetrics";
|
||||
import ProjectPage from "../pages/projectPage";
|
||||
import {ProjectSideBar} from "./ProjectSideBar";
|
||||
|
||||
export default function ProjectPageBase() {
|
||||
|
||||
const api = useApi();
|
||||
const {projectId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
}, [projectId, api])
|
||||
|
||||
useEffect(() => {fetchProject()}, [fetchProject])
|
||||
|
||||
if (!project) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 f-r h-full overflow-hidden">
|
||||
<ProjectSideBar project={project}/>
|
||||
<div className="flex-1 f-c gap-4 p-4 overflow-hidden">
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<ProjectPage/>
|
||||
}/>
|
||||
<Route path="/logs" element={
|
||||
<ProjectLogsPage/>
|
||||
}/>
|
||||
<Route path="/metrics" element={
|
||||
<ProjectMetricsPage/>
|
||||
}/>
|
||||
<Route path="/metrics/:metricId/history" element={
|
||||
<MetricHistoryPage/>
|
||||
}/>
|
||||
<Route path="/api-keys" element={
|
||||
<ProjectApiKeysPage/>
|
||||
}/>
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
84
web-app/src/components/ProjectSideBar.tsx
Normal file
84
web-app/src/components/ProjectSideBar.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import {useState} from "react";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import AppName from "./AppName";
|
||||
import {MenuList, MenuListItem} from "./MenuList";
|
||||
import {BookOpenIcon, ChartBarIcon, CollectionIcon, KeyIcon, ViewGridIcon} from "@heroicons/react/outline"
|
||||
import {Project} from "../core/Project";
|
||||
|
||||
const topSide = [
|
||||
{
|
||||
name: "Dashboard",
|
||||
icon: ViewGridIcon,
|
||||
route: "/"
|
||||
},
|
||||
{
|
||||
name: "Logs",
|
||||
icon: CollectionIcon,
|
||||
route: "/logs"
|
||||
},
|
||||
{
|
||||
name: "Metrics",
|
||||
icon: ChartBarIcon,
|
||||
route: "/metrics"
|
||||
},
|
||||
{
|
||||
name: "Api keys",
|
||||
icon: KeyIcon,
|
||||
route: "/api-keys"
|
||||
}
|
||||
]
|
||||
|
||||
export type ProjectSideBarProps = {
|
||||
project?: Project
|
||||
}
|
||||
|
||||
export function ProjectSideBar({project}: ProjectSideBarProps) {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 p-4 flex flex-col justify-between items-center gap-2">
|
||||
<div className="f-c flex-1 justify-start">
|
||||
<Link to={api.IsLogged() ? "/projects" : "/"} className="mb-8">
|
||||
<AppName/>
|
||||
</Link>
|
||||
{project && topSide.map(elem => (
|
||||
<Link key={elem.route} to={`/projects/${project.id}${elem.route}`} className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
|
||||
<elem.icon className="w-8 h-8"/>
|
||||
<div className="text-xl">{elem.name}</div>
|
||||
</Link>)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 f-c gap-4 items-center justify-end">
|
||||
<a href={process.env.REACT_APP_DOCS_URL} target="_blank" className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
|
||||
<BookOpenIcon className="w-8 h-8"/>
|
||||
<div className="text-xl">Docs</div>
|
||||
</a>
|
||||
{
|
||||
api.GetUserData() ?
|
||||
<>
|
||||
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
|
||||
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
|
||||
{api.GetUserData().email}
|
||||
</div>
|
||||
<MenuList show={showMenu}>
|
||||
{/*<MenuListItem text="Account settings" onClick={() => { console.log("Account settings") }}/>*/}
|
||||
<MenuListItem text="Logout" onClick={async () => {
|
||||
await api.Logout();
|
||||
navigate("/")
|
||||
}}/>
|
||||
</MenuList>
|
||||
</div>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
20
web-app/src/components/RequireAuth.tsx
Normal file
20
web-app/src/components/RequireAuth.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {useState} from "react"
|
||||
import {Navigate} from "react-router-dom"
|
||||
import {useApi} from "../api"
|
||||
import {useModal} from "../services/ModalService"
|
||||
|
||||
export function RequireAuth ({children}: {children: JSX.Element}) {
|
||||
const api = useApi()
|
||||
const modalService = useModal()
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return <Navigate to="/"/>
|
||||
}
|
||||
|
||||
if (!api.IsLoadingUser() && !api.GetUserData() && !hasError) {
|
||||
setHasError(true)
|
||||
modalService.error("Could not retrieve user data")
|
||||
}
|
||||
return children
|
||||
}
|
32
web-app/src/components/Select.tsx
Normal file
32
web-app/src/components/Select.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {SelectHTMLAttributes} from "react"
|
||||
import {UseFormRegisterReturn} from "react-hook-form"
|
||||
|
||||
interface LineEditProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
register?: UseFormRegisterReturn,
|
||||
label?: string,
|
||||
labelError?: string,
|
||||
isValid?: (value: string) => boolean
|
||||
children: JSX.Element | JSX.Element[]
|
||||
}
|
||||
|
||||
export default function Select({children, label, labelError, isValid, register, ...props}: LineEditProps) {
|
||||
let labelDiv = null
|
||||
if (label) {
|
||||
labelDiv = <div className="text-sm mb-1 ml-1">{label}</div>
|
||||
}
|
||||
|
||||
let labelErrorDiv = null
|
||||
if (labelError) {
|
||||
labelErrorDiv = <div className="text-red-300">{labelError}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
{labelDiv}
|
||||
<select {...props} {...register} className="bg-neutral-700 text-white rounded-md p-2">
|
||||
{children}
|
||||
</select>
|
||||
{labelErrorDiv}
|
||||
</div>
|
||||
)
|
||||
}
|
60
web-app/src/components/SideBar.tsx
Normal file
60
web-app/src/components/SideBar.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {useState} from "react";
|
||||
import {Link, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import AppName from "./AppName";
|
||||
import {MenuList, MenuListItem} from "./MenuList";
|
||||
import {CollectionIcon} from "@heroicons/react/outline"
|
||||
|
||||
const topSide = [
|
||||
{
|
||||
name: "Projects",
|
||||
icon: CollectionIcon,
|
||||
route: "/projects"
|
||||
}
|
||||
]
|
||||
|
||||
export default function SideBar() {
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
if (!api.IsLogged()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-neutral-800 p-4 flex flex-col justify-between items-center gap-2">
|
||||
<div className="f-c flex-1 justify-start">
|
||||
<Link to={api.IsLogged() ? "/projects" : "/"} className="mb-8">
|
||||
<AppName/>
|
||||
</Link>
|
||||
{topSide.map(elem => (
|
||||
<Link key={elem.route} to={elem.route} className="f-r items-center gap-4 p-4 hover:bg-neutral-600 rounded-md duration-200">
|
||||
<elem.icon className="w-8 h-8"/>
|
||||
<div className="text-xl">{elem.name}</div>
|
||||
</Link>)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-end">
|
||||
{
|
||||
api.GetUserData() ?
|
||||
<>
|
||||
<div className="p-1 px-2 border border-neutral-500 rounded-md relative">
|
||||
<div className="cursor-pointer" onClick={() => { setShowMenu(!showMenu) }}>
|
||||
{api.GetUserData().email}
|
||||
</div>
|
||||
<MenuList show={showMenu}>
|
||||
{/*<MenuListItem text="Account settings" onClick={() => { console.log("Account settings") }}/>*/}
|
||||
<MenuListItem text="Logout" onClick={async () => {
|
||||
await api.Logout();
|
||||
navigate("/")
|
||||
}}/>
|
||||
</MenuList>
|
||||
</div>
|
||||
</>
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
35
web-app/src/components/SideMenu.tsx
Normal file
35
web-app/src/components/SideMenu.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import {useState} from "react"
|
||||
|
||||
interface SideMenuProps {
|
||||
items: {
|
||||
label: string
|
||||
}[]
|
||||
onChange: (index: number) => void
|
||||
}
|
||||
|
||||
export function SideMenu({items, onChange}: SideMenuProps) {
|
||||
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="f-c p-2 gap-2 bg-neutral-600 rounded-md text-left cursor-pointer">
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={index === selectedIndex
|
||||
? "bg-neutral-500 p-1 px-2 rounded-md whitespace-nowrap"
|
||||
: "p-1 px-2 hover:bg-neutral-500 duration-100 rounded-md whitespace-nowrap"}
|
||||
onClick={() => {
|
||||
setSelectedIndex(index)
|
||||
onChange(index)
|
||||
}}>{item.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
13
web-app/src/components/Spinner.tsx
Normal file
13
web-app/src/components/Spinner.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
export interface SpinnerProps {
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function Spinner({size = 3}: SpinnerProps) {
|
||||
return (
|
||||
<div className="flex justify-center items-center">
|
||||
<div className="spinner-border animate-spin inline-block border-2 rounded-full" role="status" style={{width: size, height: size}}>
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
37
web-app/src/components/Tabs.tsx
Normal file
37
web-app/src/components/Tabs.tsx
Normal file
|
@ -0,0 +1,37 @@
|
|||
import {useState} from "react"
|
||||
|
||||
interface TabsProps {
|
||||
tabs: {
|
||||
name: string,
|
||||
renderer: JSX.Element
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function Tabs({tabs}: TabsProps) {
|
||||
|
||||
const [selectedTab, SetSelectedTab] = useState(0)
|
||||
|
||||
const selectedButtonClassName = "border-b border-b-app-primary text-awary-primary box-border p-4 cursor-pointer"
|
||||
const unselectedButtonClassName = "border-b border-neutral-600 box-border p-4 cursor-pointer"
|
||||
|
||||
const bar = tabs.map((tab, index) =>
|
||||
<div
|
||||
key={index}
|
||||
className={index === selectedTab ? selectedButtonClassName : unselectedButtonClassName}
|
||||
onClick={() => {SetSelectedTab(index)}}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-row border-b border-b-neutral-500">
|
||||
{bar}
|
||||
</div>
|
||||
<div>
|
||||
{tabs[selectedTab].renderer}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
12
web-app/src/components/TagSticker.tsx
Normal file
12
web-app/src/components/TagSticker.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { TagData } from "../core/Tag"
|
||||
|
||||
export function TagSticker({tag}: {tag: TagData}) {
|
||||
return (
|
||||
<div
|
||||
className="border border-neutral-400 rounded-md text-xs py-1 px-4"
|
||||
style={{color: tag.color, borderColor: tag.color}}
|
||||
>
|
||||
{tag.name}
|
||||
</div>
|
||||
)
|
||||
}
|
45
web-app/src/components/URLEdit.tsx
Normal file
45
web-app/src/components/URLEdit.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {UseFormRegisterReturn} from "react-hook-form"
|
||||
|
||||
interface URLEditProps {
|
||||
state?: string,
|
||||
stateHandler?: (value: string) => void,
|
||||
registerMethod?: UseFormRegisterReturn,
|
||||
registerUrl?: UseFormRegisterReturn,
|
||||
label?: string,
|
||||
labelError?: string,
|
||||
isValid?: (value: string) => boolean
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
required?: boolean
|
||||
type?: string
|
||||
minLength?: number
|
||||
className?: string
|
||||
inputClassName?: string
|
||||
}
|
||||
|
||||
export default function URLEdit({state, stateHandler, label, labelError, isValid, registerMethod, registerUrl, value, ...props}: URLEditProps) {
|
||||
|
||||
const inputProps = {
|
||||
className: `bg-neutral-700 text-white rounded-r-md p-2 flex-1 min-w-0 ${props.inputClassName}`,
|
||||
onChange: ((e: any) => props.onChange && props.onChange(e.target.value)),
|
||||
required: props.required,
|
||||
type: props.type,
|
||||
minLength: props.minLength,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`text-left min-w-0 ${props.className}`}>
|
||||
{label && <div className="text-sm mb-1 ml-1">{label}</div>}
|
||||
<div className="f-r">
|
||||
<select {...registerMethod} className="bg-neutral-700 text-white rounded-l-md p-2 border-r border-r-neutral-500">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
<input {...inputProps} {...registerUrl}/>
|
||||
</div>
|
||||
{labelError && <div className="text-red-300">{labelError}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
15
web-app/src/core/ApiKey.ts
Normal file
15
web-app/src/core/ApiKey.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export interface ApiKeyData {
|
||||
id: string,
|
||||
key: string,
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ApiKey extends Readonly<ApiKeyData> {}
|
||||
|
||||
export class ApiKey {
|
||||
|
||||
constructor(data: ApiKeyData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
}
|
23
web-app/src/core/Log.ts
Normal file
23
web-app/src/core/Log.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
export interface LogData {
|
||||
id: string,
|
||||
title: string,
|
||||
content: string,
|
||||
tags: {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}[]
|
||||
projectId: string
|
||||
createdAt: number
|
||||
createdBy: string
|
||||
}
|
||||
|
||||
export interface Log extends Readonly<LogData> {}
|
||||
|
||||
export class Log {
|
||||
|
||||
constructor(data: LogData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
}
|
34
web-app/src/core/Metric.ts
Normal file
34
web-app/src/core/Metric.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
export interface MetricDataOnCreation {
|
||||
projectId: string
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface MetricDataOnUpdate {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface MetricData {
|
||||
id: string
|
||||
projectId: string
|
||||
name: string
|
||||
color: string
|
||||
history: {
|
||||
id: string
|
||||
seriesId: string
|
||||
date: number
|
||||
value: number
|
||||
}[] | null
|
||||
currentValue?: number
|
||||
}
|
||||
|
||||
export interface Metric extends Readonly<MetricData> {}
|
||||
|
||||
export class Metric {
|
||||
|
||||
constructor(data: MetricData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
}
|
15
web-app/src/core/Project.ts
Normal file
15
web-app/src/core/Project.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import {TagData} from "./Tag";
|
||||
|
||||
export interface ProjectData {
|
||||
id: string,
|
||||
name: string
|
||||
tags: TagData[]
|
||||
}
|
||||
|
||||
export interface Project extends Readonly<ProjectData> {}
|
||||
|
||||
export class Project {
|
||||
constructor(data: ProjectData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
14
web-app/src/core/Tag.ts
Normal file
14
web-app/src/core/Tag.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
export interface TagData {
|
||||
id?: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface Tag extends Readonly<TagData> {}
|
||||
|
||||
export class Tag {
|
||||
|
||||
constructor(data: TagData) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
30
web-app/src/core/View.ts
Normal file
30
web-app/src/core/View.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
export interface ViewDataOnCreation<T> {
|
||||
name: string
|
||||
type: string
|
||||
provider: string
|
||||
config: T
|
||||
}
|
||||
|
||||
export interface ViewDataOnUpdate<T> {
|
||||
name: string
|
||||
config: T
|
||||
}
|
||||
|
||||
export interface ViewData<T> {
|
||||
id: string,
|
||||
projectId: string,
|
||||
type: string
|
||||
name: string
|
||||
provider: string
|
||||
config: T
|
||||
}
|
||||
|
||||
export interface View<T> extends Readonly<ViewData<T>> {}
|
||||
|
||||
export class View<T> {
|
||||
|
||||
constructor(data: ViewData<T>) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
}
|
32
web-app/src/forms/createMetricForm.tsx
Normal file
32
web-app/src/forms/createMetricForm.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../components/Button";
|
||||
import LineEdit from "../components/Input";
|
||||
import {Metric, MetricDataOnUpdate} from "../core/Metric";
|
||||
|
||||
interface CreateProjectFormProps {
|
||||
metric?: Metric // If provided, we're on an update, otherwise it's a creation.
|
||||
close: any
|
||||
onCreate: (data: MetricDataOnUpdate) => void
|
||||
}
|
||||
|
||||
export function MetricForm({metric, close, onCreate}: CreateProjectFormProps) {
|
||||
const {register, handleSubmit} = useForm<MetricDataOnUpdate>({
|
||||
defaultValues: {
|
||||
name: metric?.name,
|
||||
type: "numeric"
|
||||
}
|
||||
});
|
||||
|
||||
const createMetric: SubmitHandler<MetricDataOnUpdate> = async (data) => {
|
||||
onCreate(data);
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createMetric)} className="f-c gap-2">
|
||||
<h2 className="text-2xl mb-4 text-left">{metric ? "Update" : "Create"} metric</h2>
|
||||
<LineEdit label="Name" register={register("name")}/>
|
||||
<Button type="submit" text={metric ? "Update" : "Create"} className="ml-auto"/>
|
||||
</form>
|
||||
)
|
||||
}
|
26
web-app/src/forms/createProjectForm.tsx
Normal file
26
web-app/src/forms/createProjectForm.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../components/Button";
|
||||
import LineEdit from "../components/Input";
|
||||
import {ProjectData} from "../core/Project"
|
||||
|
||||
interface CreateProjectFormProps {
|
||||
close: any
|
||||
onCreate: any
|
||||
}
|
||||
|
||||
export function CreateProjectForm(props: CreateProjectFormProps) {
|
||||
const {register, handleSubmit} = useForm<Omit<ProjectData, "id">>();
|
||||
|
||||
const createProject: SubmitHandler<Omit<ProjectData, "id">> = async (data) => {
|
||||
props.onCreate(data);
|
||||
props.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createProject)} className="f-c gap-2">
|
||||
<h2 className="text-2xl mb-4 text-left">Create a new project</h2>
|
||||
<LineEdit label="Name" register={register("name")}/>
|
||||
<Button type="submit" text="Create"/>
|
||||
</form>
|
||||
)
|
||||
}
|
35
web-app/src/index.css
Normal file
35
web-app/src/index.css
Normal file
|
@ -0,0 +1,35 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
.f-r {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.f-c {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
color: theme('colors.white');
|
||||
background-color: theme('colors.neutral.600');
|
||||
border-radius: theme('borderRadius.md');
|
||||
}
|
||||
}
|
19
web-app/src/index.tsx
Normal file
19
web-app/src/index.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
1
web-app/src/logo.svg
Normal file
1
web-app/src/logo.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
After Width: | Height: | Size: 2.6 KiB |
60
web-app/src/pages/EmailConfirmation.tsx
Normal file
60
web-app/src/pages/EmailConfirmation.tsx
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {CheckIcon} from "@heroicons/react/outline";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useNavigate, useSearchParams} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import Button from "../components/Button";
|
||||
import Spinner from "../components/Spinner";
|
||||
|
||||
export default function EmailConfirmation() {
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [error, setError] = useState(false)
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams();
|
||||
const email = searchParams.get("email")
|
||||
const emailConfirmationToken = searchParams.get("emailConfirmationToken")
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!email || !emailConfirmationToken) {
|
||||
navigate("/")
|
||||
return;
|
||||
}
|
||||
setValidating(true)
|
||||
api.VerifyEmail(email, emailConfirmationToken).then(success => {
|
||||
setValidating(false)
|
||||
setError(success)
|
||||
}).catch(() => {
|
||||
setValidating(false)
|
||||
setError(true)
|
||||
})
|
||||
}, [api, navigate, setValidating, setError, email, emailConfirmationToken])
|
||||
|
||||
return (
|
||||
<div className="p-4 f-c gap-4 text-left">
|
||||
{email}
|
||||
<div className="f-r gap-4 items-center">
|
||||
{validating &&
|
||||
<>
|
||||
<Spinner size={16}/>
|
||||
Verifying your email
|
||||
</>
|
||||
}
|
||||
{!validating && !error &&
|
||||
<>
|
||||
<CheckIcon className="w-8 h-8 text-green-300"/>
|
||||
Email verified!
|
||||
</>
|
||||
}
|
||||
{!validating && error &&
|
||||
<>
|
||||
<CheckIcon className="w-8 h-8 text-red-300"/>
|
||||
<p>An error occured or you may have already verified your email.<br/>
|
||||
Contact us if the problem persists</p>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{!validating && <Button text="Go to login page" onClick={() => {navigate('/')}}/>}
|
||||
</div>
|
||||
)
|
||||
}
|
73
web-app/src/pages/MetricHistory.tsx
Normal file
73
web-app/src/pages/MetricHistory.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import {TrashIcon} from "@heroicons/react/outline";
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import {useParams} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import Button from "../components/Button";
|
||||
import {Metric} from "../core/Metric";
|
||||
import {Project} from "../core/Project";
|
||||
import {MetricForm} from "../forms/createMetricForm";
|
||||
import {useModal} from "../services/ModalService";
|
||||
import {formatTimestamp} from "../utils/formatTimestamp";
|
||||
|
||||
export function MetricHistoryPage() {
|
||||
|
||||
const api = useApi();
|
||||
const modalService = useModal();
|
||||
const {projectId, metricId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [metric, setMetric] = useState<Metric | null>(null);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
const metric = await api.fetchProjectMetric(projectId as string, metricId as string);
|
||||
setMetric(metric);
|
||||
}, [projectId, metricId, api])
|
||||
|
||||
useEffect(() => {fetchProject()}, [fetchProject])
|
||||
|
||||
const onSomethingChanged = () => {
|
||||
fetchProject();
|
||||
}
|
||||
|
||||
if (!project || !metric) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
const metricForm = (close: () => void) =>
|
||||
<MetricForm
|
||||
close={close}
|
||||
onCreate={async (data) => {
|
||||
close()
|
||||
await api.createSeries(project.id, data)
|
||||
onSomethingChanged()
|
||||
}}
|
||||
/>
|
||||
|
||||
const deleteRecord = async (recordId: string) => {
|
||||
await api.deleteMetricHistory(metric.projectId, metric.id, recordId);
|
||||
fetchProject();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 f-c gap-4 overflow-scroll">
|
||||
<div className="f-r justify-between items-center border-b border-neutral-600">
|
||||
<h2 className="text-left text-3xl font-bold pb-4">Metrics</h2>
|
||||
<Button text="Create new metric" onClick={() => modalService.addModal(metricForm)}/>
|
||||
</div>
|
||||
<div className="f-c gap-2 overflow-scroll pr-2">
|
||||
{metric?.history?.map(record =>
|
||||
<div className="card f-r gap-4 p-2">
|
||||
<div>Value: {record.value}</div>
|
||||
<div>Date: {formatTimestamp(record.date)}</div>
|
||||
<TrashIcon
|
||||
className="ml-auto w-4 h-4 text-red-300 hover:bg-neutral-500 cursor-pointer duration-75 mr-2"
|
||||
|
||||
onClick={() => modalService.confirmation("Delete this history record ?", () => deleteRecord(record.id))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
80
web-app/src/pages/ProjectApiKeys.tsx
Normal file
80
web-app/src/pages/ProjectApiKeys.tsx
Normal file
|
@ -0,0 +1,80 @@
|
|||
import {useCallback, useEffect, useState} from "react"
|
||||
import {useParams} from "react-router-dom"
|
||||
import {useApi} from "../api"
|
||||
import Button from "../components/Button"
|
||||
import Card from "../components/Card"
|
||||
import ProjectHeader from "../components/ProjectHeader"
|
||||
import {ApiKey, ApiKeyData} from "../core/ApiKey"
|
||||
import {Project} from "../core/Project"
|
||||
import {useModal} from "../services/ModalService"
|
||||
import {ApiKeyForm} from "./projectPage/forms/apiKeyForm"
|
||||
|
||||
|
||||
export function ProjectApiKeysPage() {
|
||||
const api = useApi()
|
||||
const modalService = useModal()
|
||||
const {projectId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
const keys = await api.fetchProjectApiKeys(project.id);
|
||||
setKeys(keys)
|
||||
}, [projectId, api])
|
||||
|
||||
useEffect(() => {fetchData()}, [fetchData])
|
||||
|
||||
if (!project) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
const generateApiKey = async (data: Omit<ApiKeyData, "id">) => {
|
||||
await api.generateApiKey(project.id, data);
|
||||
fetchData();
|
||||
}
|
||||
|
||||
const deleteApiKey = async (id: string) => {
|
||||
await api.deleteApiKey(project.id, id);
|
||||
fetchData();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-c gap-4 overflow-scroll">
|
||||
<ProjectHeader
|
||||
project={project}
|
||||
middle={<h3 className="text-xl">Api keys</h3>}
|
||||
right={<Button
|
||||
className="ml-auto"
|
||||
text="Generate API key"
|
||||
onClick={() => {
|
||||
modalService.addModal((close) =>
|
||||
<ApiKeyForm close={close} onCreate={generateApiKey}/>)
|
||||
}
|
||||
}
|
||||
/>}
|
||||
/>
|
||||
{
|
||||
keys.map(key =>
|
||||
<Card
|
||||
key={key.id}
|
||||
header={
|
||||
<div className="f-r justify-between">
|
||||
{key.name}
|
||||
<Button
|
||||
text="Delete"
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
modalService.confirmation("Delete key ?", () => deleteApiKey(key.id))
|
||||
}}
|
||||
/>
|
||||
</div>}
|
||||
>
|
||||
{key.key}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
86
web-app/src/pages/ProjectLogs.tsx
Normal file
86
web-app/src/pages/ProjectLogs.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import {useCallback, useEffect, useState} from "react";
|
||||
import {useParams} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import Button from "../components/Button";
|
||||
import {LogCard} from "../components/LogCard";
|
||||
import ProjectHeader from "../components/ProjectHeader";
|
||||
import {Log} from "../core/Log";
|
||||
import {Project} from "../core/Project";
|
||||
import { Tag, TagData } from "../core/Tag";
|
||||
import {useModal} from "../services/ModalService";
|
||||
import {TagsSettings} from "./projectPage/TagsSettings";
|
||||
|
||||
export default function ProjectLogsPage() {
|
||||
|
||||
const api = useApi();
|
||||
const modalService = useModal();
|
||||
const {projectId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
const logs = await api.fetchProjectLogs(projectId as string);
|
||||
setLogs(logs);
|
||||
const tags = await api.fetchProjectTags(projectId as string);
|
||||
setTags(tags);
|
||||
}, [projectId, api])
|
||||
|
||||
const onLogDeleted = () => {
|
||||
fetchProject();
|
||||
}
|
||||
|
||||
useEffect(() => {fetchProject()}, [fetchProject])
|
||||
|
||||
if (!project) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
const updateTags = async (newTags: TagData[]) => {
|
||||
const tagsToCreate = newTags.filter(newTag => !newTag.id)
|
||||
const tagsToDelete = tags.filter(tag => !newTags.find(newTag => newTag.id === tag.id))
|
||||
const tagsToUpdate = newTags.filter(newTag => {
|
||||
const tag = tags.find(tag => tag.id === newTag.id)
|
||||
if (!tag)
|
||||
return false
|
||||
return tag.name !== newTag.name || tag.color !== newTag.color
|
||||
})
|
||||
await Promise.all(tagsToUpdate.map(async tag => {
|
||||
if (tag.id)
|
||||
return api.updateTag(project.id, tag.id, tag)
|
||||
}))
|
||||
await Promise.all(tagsToCreate.map(async tag => {
|
||||
return api.createTag(project.id, tag)
|
||||
}))
|
||||
await Promise.all(tagsToDelete.map(async tag => {
|
||||
if (tag.id)
|
||||
return api.deleteTag(project.id, tag.id)
|
||||
}))
|
||||
await fetchProject()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 f-c gap-4">
|
||||
<ProjectHeader
|
||||
project={project}
|
||||
middle={<h3 className="text-xl">Logs</h3>}
|
||||
right={
|
||||
<Button
|
||||
className="ml-auto"
|
||||
text="Manage tags"
|
||||
onClick={() => modalService.addModal(
|
||||
(close) => <TagsSettings tags={tags} onSave={(data) => {
|
||||
updateTags(data);close()
|
||||
}}/>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={`flex flex-col rounded-md gap-2 overflow-scroll`}>
|
||||
{logs.map(log => <LogCard key={log.id} project={project} log={log} onDelete={onLogDeleted}/>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
153
web-app/src/pages/ProjectMetrics.tsx
Normal file
153
web-app/src/pages/ProjectMetrics.tsx
Normal file
|
@ -0,0 +1,153 @@
|
|||
import {ClipboardCopyIcon, CogIcon, DocumentSearchIcon, PlusIcon, TrashIcon} from "@heroicons/react/outline";
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import {Link, useParams} from "react-router-dom";
|
||||
import {useApi} from "../api";
|
||||
import Button from "../components/Button";
|
||||
import ProjectHeader from "../components/ProjectHeader";
|
||||
import {Metric, MetricDataOnUpdate} from "../core/Metric";
|
||||
import {Project} from "../core/Project";
|
||||
import {MetricForm} from "../forms/createMetricForm";
|
||||
import {useModal} from "../services/ModalService";
|
||||
import { copyToClipboard } from "../utils/copyToClipboard";
|
||||
import {formatTimestamp} from "../utils/formatTimestamp";
|
||||
import {AddSerieValueForm} from "./projectPage/forms/AddSeriesValueForm";
|
||||
|
||||
export type MetricCardProps = {
|
||||
metric: Metric
|
||||
onDelete: () => void
|
||||
onAddDataPoint: () => void
|
||||
}
|
||||
|
||||
export function MetricCard({metric, onDelete, onAddDataPoint}: MetricCardProps) {
|
||||
const modalService = useModal();
|
||||
const api = useApi()
|
||||
|
||||
const addDataPoint = async (value: number) => {
|
||||
await api.addValueToSerie(metric.projectId, metric.id, value)
|
||||
onAddDataPoint()
|
||||
}
|
||||
|
||||
const updateMetric = async (data: MetricDataOnUpdate) => {
|
||||
await api.updateMetric(metric.projectId, metric.id, data)
|
||||
onDelete() // I'm lazy
|
||||
}
|
||||
|
||||
const deleteMetric = async () => {
|
||||
await api.deleteMetric(metric.projectId, metric.id);
|
||||
onDelete()
|
||||
}
|
||||
|
||||
const metricForm = (close: () => void) =>
|
||||
<MetricForm
|
||||
metric={metric}
|
||||
close={close}
|
||||
onCreate={updateMetric}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div className="card f-r">
|
||||
<div className="flex-1 f-c">
|
||||
<div className="f-r">
|
||||
<div className="flex-1"></div>
|
||||
<div className="f-r gap-2 items-center">
|
||||
<p className="flex-1 text-center text-xl font-bold">{metric.name}</p>
|
||||
<ClipboardCopyIcon
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
copyToClipboard(metric.id)
|
||||
modalService.info("Metric id copied to clipboard")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 f-r gap-2 justify-end items-center">
|
||||
<PlusIcon
|
||||
className="w-4 h-4 text-green-300 hover:bg-neutral-500 cursor-pointer duration-75"
|
||||
onClick={() => modalService.addModal((close) =>
|
||||
<AddSerieValueForm series={metric} close={close} onCreate={addDataPoint} />
|
||||
)}
|
||||
/>
|
||||
<CogIcon
|
||||
className="w-4 h-4 text-neutral-300 hover:bg-neutral-500 cursor-pointer duration-75"
|
||||
onClick={() => modalService.addModal(metricForm)}
|
||||
/>
|
||||
<Link to={`${metric.id}/history`}>
|
||||
<DocumentSearchIcon
|
||||
className="w-4 h-4 text-neutral-300 hover:bg-neutral-500 cursor-pointer duration-75"
|
||||
/>
|
||||
</Link>
|
||||
<TrashIcon
|
||||
className="w-4 h-4 text-red-300 hover:bg-neutral-500 cursor-pointer duration-75 mr-2"
|
||||
onClick={() => modalService.confirmation(`Delete the metric "${metric.name}" ?`, deleteMetric)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="f-c text-left p-2">
|
||||
<span>Last update: {formatTimestamp(metric.history?.at(0)?.date || 0)}</span>
|
||||
<span>Last value: {metric.history?.at(0)?.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ProjectMetricsPage() {
|
||||
|
||||
const api = useApi();
|
||||
const modalService = useModal();
|
||||
const {projectId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
const metrics = await api.fetchProjectMetrics(projectId as string);
|
||||
setMetrics(metrics);
|
||||
}, [projectId, api])
|
||||
|
||||
useEffect(() => {fetchProject()}, [fetchProject])
|
||||
|
||||
const onSomethingChanged = () => {
|
||||
fetchProject();
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
const metricForm = (close: () => void) =>
|
||||
<MetricForm
|
||||
close={close}
|
||||
onCreate={async (data) => {
|
||||
close()
|
||||
await api.createSeries(project.id, data)
|
||||
onSomethingChanged()
|
||||
}}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div className="flex-1 f-c gap-4 overflow-scroll">
|
||||
<ProjectHeader
|
||||
project={project}
|
||||
middle={<h3 className="text-xl">Metrics</h3>}
|
||||
right={
|
||||
<Button
|
||||
className="ml-auto"
|
||||
text="Create new metric"
|
||||
onClick={() => modalService.addModal(metricForm)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className={`grid grid-cols-2 rounded-md gap-2 overflow-scroll pr-2`}>
|
||||
{metrics.map(metric =>
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
metric={metric}
|
||||
onDelete={onSomethingChanged}
|
||||
onAddDataPoint={onSomethingChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
106
web-app/src/pages/Signup.tsx
Normal file
106
web-app/src/pages/Signup.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import {useEffect, useState} from "react";
|
||||
import {SubmitHandler, useForm} from "react-hook-form";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api"
|
||||
import AppName from "../components/AppName";
|
||||
import Button from "../components/Button";
|
||||
import LineEdit from "../components/Input";
|
||||
import {useModal} from "../services/ModalService";
|
||||
|
||||
interface SignupForm {
|
||||
email: string
|
||||
password: string
|
||||
passwordConfirmation: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export default function SignupPage() {
|
||||
|
||||
const api = useApi();
|
||||
const navigate = useNavigate();
|
||||
const modalService = useModal();
|
||||
|
||||
const {register, watch, handleSubmit} = useForm<SignupForm>()
|
||||
const email = watch("email", "")
|
||||
const password = watch("password", "")
|
||||
const passwordConfirmation = watch("passwordConfirmation", "")
|
||||
const [errorMessage, setErrorMessage]= useState<null | string>(null)
|
||||
const [isUserRegistrationEnabled, setIsUserRegistrationEnabled] = useState<boolean | null>(null)
|
||||
|
||||
const isFormValid = () => {
|
||||
return email && email.length > 5 && password.length > 0 && passwordConfirmation === password ? true : false
|
||||
}
|
||||
|
||||
const handleCreation: SubmitHandler<SignupForm> = async ({email, password, code}) => {
|
||||
if (!isFormValid()) {
|
||||
setErrorMessage("Wrong email format or passwords don't match")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.Signup(email, password, code)
|
||||
modalService.addModal(
|
||||
(close) => {
|
||||
navigate("/")
|
||||
return (
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>Your account has been created, there is no need to confirm your email at the moment,
|
||||
you can now log in.</p>
|
||||
<Button text="Log in" onClick={() => {
|
||||
close()
|
||||
}}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
} catch (err: any) {
|
||||
console.log(err)
|
||||
setErrorMessage(err?.message || "An unknown error occured")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.isUserRegistrationEnabled().then(setIsUserRegistrationEnabled)
|
||||
}, [api, setIsUserRegistrationEnabled])
|
||||
|
||||
if (isUserRegistrationEnabled === null) {
|
||||
return <div>Check if user registration is enabled...</div>
|
||||
} else if (isUserRegistrationEnabled === false ) {
|
||||
return <div>User registration is disabled</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col justify-center items-center gap-4">
|
||||
<div className="p-4 flex flex-col justify-center items-center bg-neutral-600 rounded-md border border-neutral-500 shadow-sm">
|
||||
<AppName/>
|
||||
<form onSubmit={handleSubmit(handleCreation)} className="flex flex-col gap-4">
|
||||
<LineEdit
|
||||
type="email"
|
||||
label="Email"
|
||||
register={register("email")}
|
||||
required
|
||||
/>
|
||||
<LineEdit
|
||||
type="password"
|
||||
label="Password"
|
||||
register={register("password")}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
<LineEdit
|
||||
type="password"
|
||||
label="Confirm password"
|
||||
register={register("passwordConfirmation")}
|
||||
minLength={6}
|
||||
required
|
||||
/>
|
||||
{errorMessage && <p className="text-red-200">{errorMessage}</p>}
|
||||
<Button
|
||||
className="self-center"
|
||||
color={isFormValid() ? "primary" : "dark"}
|
||||
text="Sign up"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
68
web-app/src/pages/login.tsx
Normal file
68
web-app/src/pages/login.tsx
Normal file
|
@ -0,0 +1,68 @@
|
|||
import {useState} from "react";
|
||||
import {SubmitHandler, useForm} from "react-hook-form";
|
||||
import {Link, Navigate, useNavigate} from "react-router-dom";
|
||||
import {useApi} from "../api"
|
||||
import AppName from "../components/AppName";
|
||||
import Button from "../components/Button";
|
||||
import LineEdit from "../components/Input";
|
||||
|
||||
interface LoginForm {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
|
||||
const api = useApi();
|
||||
const navigate = useNavigate()
|
||||
const {register, setValue, handleSubmit} = useForm<LoginForm>()
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<null | string>(null);
|
||||
|
||||
if (api.IsLogged()) {
|
||||
return <Navigate to="/projects"/>
|
||||
}
|
||||
|
||||
const onLogin: SubmitHandler<LoginForm> = async ({email, password}) => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
await api.Login(email, password)
|
||||
navigate("/projects")
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
setValue("password", "");
|
||||
setErrorMessage((err as Error).message);
|
||||
}
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col justify-center items-center gap-4">
|
||||
<div className="p-4 flex flex-col justify-center items-center bg-neutral-600 rounded-md border border-neutral-500 shadow-sm">
|
||||
<AppName/>
|
||||
<form onSubmit={handleSubmit(onLogin)} className="flex flex-col gap-4 mt-4">
|
||||
<LineEdit
|
||||
type="email"
|
||||
label="Email"
|
||||
register={register("email")}
|
||||
required
|
||||
/>
|
||||
<LineEdit
|
||||
type="password"
|
||||
label="Password"
|
||||
register={register("password")}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
className="self-center"
|
||||
color="green"
|
||||
text="Log in"
|
||||
spinner={isLoggingIn}
|
||||
/>
|
||||
{errorMessage && <p className="text-red-200">{errorMessage}</p>}
|
||||
</form>
|
||||
</div>
|
||||
<Link to="/signup"><Button text="Create an account" color="dark"/></Link>
|
||||
</div>
|
||||
)
|
||||
}
|
20
web-app/src/pages/projectPage/LogsTab.tsx
Normal file
20
web-app/src/pages/projectPage/LogsTab.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import {TinyLogCard} from "../../components/LogCard";
|
||||
import {Log} from "../../core/Log";
|
||||
import {Project} from "../../core/Project";
|
||||
|
||||
interface LogsTabProps {
|
||||
className?: string
|
||||
project: Project
|
||||
logs: Log[]
|
||||
}
|
||||
|
||||
export default function LogsTab({project, logs, className}: LogsTabProps) {
|
||||
return (
|
||||
<div className={`bg-neutral-600 rounded-md ${className}`}>
|
||||
<div className="f-c gap-2 m-2">
|
||||
<p className="text-left font-bold mb-4">Logs</p>
|
||||
{logs.map(log => <TinyLogCard key={log.id} project={project} log={log}/>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
58
web-app/src/pages/projectPage/MetricsPanel.tsx
Normal file
58
web-app/src/pages/projectPage/MetricsPanel.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import {Metric} from "../../core/Metric";
|
||||
import {View} from "../../core/View";
|
||||
import {DashboardView} from "./forms/OrganizeDashboardForm";
|
||||
|
||||
interface MetricCardProps {
|
||||
metric: Metric
|
||||
color: string
|
||||
order: number
|
||||
}
|
||||
|
||||
function MetricCard({info}: {info: MetricCardProps}) {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="card p-4 f-c text-center" style={{color: info.color || info.metric.color || "white"}}>
|
||||
<p className="text-3xl">{info.metric.currentValue || "0"}</p>
|
||||
<p>{info.metric.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
interface MetricsPanelProps {
|
||||
metrics: Metric[]
|
||||
dashboardConfig: View<DashboardView> | null
|
||||
}
|
||||
|
||||
export function MetricsPanel({metrics, dashboardConfig}: MetricsPanelProps) {
|
||||
let metricsToShow: MetricCardProps[] = []
|
||||
if (dashboardConfig) {
|
||||
const metricsConfig = dashboardConfig.config.metrics
|
||||
for (let i = 0; i < metricsConfig.length; ++i) {
|
||||
if (!metricsConfig[i].show) {
|
||||
continue
|
||||
}
|
||||
const matchedMetric = metrics.find(metric => metric.id === metricsConfig[i].metricId)
|
||||
if (!matchedMetric) {
|
||||
continue
|
||||
}
|
||||
metricsToShow.push({
|
||||
metric: matchedMetric,
|
||||
color: metricsConfig[i].color,
|
||||
order: metricsConfig[i].order
|
||||
})
|
||||
}
|
||||
metricsToShow.sort((a, b) => a.order - b.order)
|
||||
}
|
||||
return (
|
||||
<div className="f-r flex-wrap gap-2 overflow-scroll">
|
||||
{metricsToShow.map(metric =>
|
||||
<MetricCard
|
||||
key={metric.metric.id}
|
||||
info={metric}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
85
web-app/src/pages/projectPage/TagsSettings.tsx
Normal file
85
web-app/src/pages/projectPage/TagsSettings.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import {TrashIcon} from "@heroicons/react/outline"
|
||||
import {useState} from "react"
|
||||
import Button from "../../components/Button"
|
||||
import Card from "../../components/Card"
|
||||
import LineEdit from "../../components/Input"
|
||||
import {TagData} from "../../core/Tag"
|
||||
import {useModal} from "../../services/ModalService"
|
||||
import {TagForm} from "./forms/TagForm"
|
||||
|
||||
function clone<T>(x: T): T {
|
||||
return JSON.parse(JSON.stringify(x))
|
||||
}
|
||||
|
||||
interface TagsSettingsProps {
|
||||
tags: TagData[]
|
||||
onSave: (tags: TagData[]) => void
|
||||
}
|
||||
|
||||
export function TagsSettings({tags: originalTags, onSave}: TagsSettingsProps) {
|
||||
const modalService = useModal()
|
||||
|
||||
const [tags, setTags] = useState(originalTags)
|
||||
|
||||
const addTag = (tag: TagData) => {
|
||||
const newTags = clone(tags)
|
||||
newTags.push(tag)
|
||||
setTags(newTags)
|
||||
}
|
||||
|
||||
const deleteTag = (tag: TagData) => {
|
||||
const newTags = clone(tags).filter(newTags => newTags.id !== tag.id)
|
||||
setTags(newTags)
|
||||
}
|
||||
|
||||
const saveTags = async () => {
|
||||
onSave(tags);
|
||||
}
|
||||
|
||||
const header =
|
||||
<div className="f-r justify-between gap-4">
|
||||
<h2>Tags</h2>
|
||||
<Button
|
||||
text="Create tag"
|
||||
onClick={() => {modalService.addModal((close) => <TagForm close={close} onCreate={addTag}/>)}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
const setNameOfTag = (tagIndex: number, name: string) => {
|
||||
const newTags = clone(tags)
|
||||
newTags[tagIndex].name = name
|
||||
setTags(newTags)
|
||||
}
|
||||
|
||||
const setColorOfTag = (tagIndex: number, color: string) => {
|
||||
const newTags = clone(tags)
|
||||
newTags[tagIndex].color = color
|
||||
setTags(newTags)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-c gap-4 flex-1">
|
||||
<Card header={header}>
|
||||
<div className="f-c gap-4">
|
||||
{tags.map((tag, tagIndex) =>
|
||||
<div className="f-c gap-1">
|
||||
<div key={tagIndex} className="f-r items-center gap-2">
|
||||
<LineEdit value={tag.name} onChange={(value) => {setNameOfTag(tagIndex, value)}}/>
|
||||
<input type="color" value={tag.color} onChange={(event) => {setColorOfTag(tagIndex, event.target.value)}}/>
|
||||
<TrashIcon
|
||||
className="w-4 h-4 text-red-300 cursor-pointer"
|
||||
// I need setTimeout or the modal will close itself, need to investigate
|
||||
onClick={() => {setTimeout(() => {deleteTag(tag)}, 0)}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-1 opacity-25 text-xs">
|
||||
id: {tag.id || "Id will appear after creation"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button text="Save" onClick={saveTags}/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
31
web-app/src/pages/projectPage/forms/AddSeriesValueForm.tsx
Normal file
31
web-app/src/pages/projectPage/forms/AddSeriesValueForm.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../../../components/Button";
|
||||
import LineEdit from "../../../components/Input";
|
||||
import {ApiKeyData} from "../../../core/ApiKey";
|
||||
import {Metric, MetricData} from "../../../core/Metric";
|
||||
import {TagData} from "../../../core/Tag";
|
||||
|
||||
interface AddSerieValueFormProps {
|
||||
series: Metric
|
||||
close: any
|
||||
onCreate: (value: number) => void
|
||||
}
|
||||
|
||||
export function AddSerieValueForm(props: AddSerieValueFormProps) {
|
||||
const {register, handleSubmit} = useForm<{value: number}>();
|
||||
|
||||
const addSeriesValue: SubmitHandler<{value: number}> = async (data) => {
|
||||
props.onCreate(data.value);
|
||||
props.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(addSeriesValue)} className="f-c gap-2">
|
||||
<h2 className="text-2xl mb-4 text-left">
|
||||
Add value to Series {props.series.name}
|
||||
</h2>
|
||||
<LineEdit label="Value" type="number" register={register("value")}/>
|
||||
<Button type="submit" text="Add"/>
|
||||
</form>
|
||||
)
|
||||
}
|
31
web-app/src/pages/projectPage/forms/CreateSeriesForm.tsx
Normal file
31
web-app/src/pages/projectPage/forms/CreateSeriesForm.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../../../components/Button";
|
||||
import LineEdit from "../../../components/Input";
|
||||
import {ApiKeyData} from "../../../core/ApiKey";
|
||||
import {Metric, MetricData} from "../../../core/Metric";
|
||||
import {TagData} from "../../../core/Tag";
|
||||
|
||||
interface CreateSeriesFormProps {
|
||||
close: any
|
||||
onCreate: (name: string, type: string) => void
|
||||
}
|
||||
|
||||
export function CreateSeriesForm(props: CreateSeriesFormProps) {
|
||||
const {register, handleSubmit} = useForm<{name: string, type: string}>();
|
||||
|
||||
const CreateSeries: SubmitHandler<{name: string, type: string}> = async (data) => {
|
||||
props.onCreate(data.name, data.type);
|
||||
props.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(CreateSeries)} className="f-c gap-2">
|
||||
<h2 className="text-2xl mb-4 text-left">
|
||||
Create Series
|
||||
</h2>
|
||||
<LineEdit label="Name" register={register("name")}/>
|
||||
<LineEdit label="Type" register={register("type")}/>
|
||||
<Button type="submit" text="Add"/>
|
||||
</form>
|
||||
)
|
||||
}
|
299
web-app/src/pages/projectPage/forms/OrganizeDashboardForm.tsx
Normal file
299
web-app/src/pages/projectPage/forms/OrganizeDashboardForm.tsx
Normal file
|
@ -0,0 +1,299 @@
|
|||
import {PlusIcon} from "@heroicons/react/outline";
|
||||
import {useEffect, useState} from "react";
|
||||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../../../components/Button";
|
||||
import LineEdit from "../../../components/Input";
|
||||
import {ApiKeyData} from "../../../core/ApiKey";
|
||||
import {Metric, MetricData} from "../../../core/Metric";
|
||||
import {TagData} from "../../../core/Tag";
|
||||
import {View} from "../../../core/View";
|
||||
import {useModal} from "../../../services/ModalService";
|
||||
|
||||
function clone<T>(x: T): T {
|
||||
return JSON.parse(JSON.stringify(x))
|
||||
}
|
||||
|
||||
interface OrganizeDashboardFormProps {
|
||||
dashboard: View<DashboardView> | null
|
||||
metrics: Metric[]
|
||||
close: any
|
||||
onSave: (newDashboardConfig: DashboardView) => void
|
||||
}
|
||||
|
||||
type DashboardViewCharts = {
|
||||
name: string
|
||||
order: number
|
||||
metrics: {
|
||||
metricId: string
|
||||
color: string
|
||||
type: string
|
||||
alwaysUseLastValue: boolean
|
||||
}[]
|
||||
}[]
|
||||
|
||||
export type DashboardView = {
|
||||
metrics: {
|
||||
metricId: string,
|
||||
name: string
|
||||
order: number,
|
||||
show: boolean
|
||||
color: string
|
||||
}[]
|
||||
charts: DashboardViewCharts
|
||||
}
|
||||
|
||||
|
||||
const optionsCss = "f-r items-center gap-2 bg-neutral-500 rounded-md"
|
||||
|
||||
export function OrganizeDashboardForm(props: OrganizeDashboardFormProps) {
|
||||
|
||||
const [showGraphs, setShowGraphs] = useState<boolean>(true)
|
||||
const [metricsOrder, setMetricsOrder] = useState<DashboardView>()
|
||||
|
||||
useEffect(() => {
|
||||
const defaultDashboard: DashboardView = {
|
||||
metrics: props.metrics.map((metric, index) => ({
|
||||
metricId: metric.id,
|
||||
name: metric.name,
|
||||
order: index,
|
||||
show: false,
|
||||
color: "#dddddd"
|
||||
})),
|
||||
charts: []
|
||||
}
|
||||
let x: DashboardView = defaultDashboard;
|
||||
if (props.dashboard) {
|
||||
x = clone(props.dashboard.config)
|
||||
props.metrics.forEach((metric, index)=> {
|
||||
if (!x.metrics.find(m => m.metricId === metric.id)) {
|
||||
x.metrics.push({
|
||||
metricId: metric.id,
|
||||
name: metric.name,
|
||||
order: index,
|
||||
show: false,
|
||||
color: "#dddddd"
|
||||
})
|
||||
}
|
||||
})
|
||||
x.metrics = x.metrics.filter(metric => props.metrics.find(m => m.id === metric.metricId))
|
||||
}
|
||||
x.metrics.sort((a, b) => a.order - b.order)
|
||||
setMetricsOrder(x)
|
||||
}, [])
|
||||
|
||||
if (!metricsOrder) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-c gap-2">
|
||||
<div className="f-r justify-around border-b border-neutral-400 pb-2">
|
||||
<Button
|
||||
text="Top row"
|
||||
color={!showGraphs ? "dark" : "brighter-dark"}
|
||||
onClick={() => {
|
||||
setShowGraphs(false)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
text="Graphs"
|
||||
color={showGraphs ? "dark" : "brighter-dark"}
|
||||
onClick={() => {
|
||||
setShowGraphs(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!showGraphs && metricsOrder.metrics.map((metric, index) => (
|
||||
<div className="f-r items-stretch border border-neutral-500 p-2 rounded-md gap-2">
|
||||
<div className="mr-auto f-r items-center">{metric.name}</div>
|
||||
<div className={`${optionsCss} p-1`}>
|
||||
Order
|
||||
<LineEdit
|
||||
inputClassName="p-0 px-1"
|
||||
type="number"
|
||||
className="w-16"
|
||||
value={metric.order}
|
||||
onChange={(valueString) => {
|
||||
const value = parseInt(valueString)
|
||||
const newMetricsOrder = clone(metricsOrder)
|
||||
newMetricsOrder.metrics[index].order = value
|
||||
setMetricsOrder(newMetricsOrder)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${optionsCss} pl-1`}>
|
||||
Color
|
||||
<input
|
||||
type="color"
|
||||
value={metric.color}
|
||||
onChange={(valueString) => {
|
||||
const checked = valueString.target.value
|
||||
const newMetricsOrder = clone(metricsOrder)
|
||||
newMetricsOrder.metrics[index].color = checked
|
||||
setMetricsOrder(newMetricsOrder)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${optionsCss} px-1`}>
|
||||
<input type="checkbox" checked={metric.show}
|
||||
onChange={(valueString) => {
|
||||
const checked = valueString.target.checked
|
||||
const newMetricsOrder = clone(metricsOrder)
|
||||
newMetricsOrder.metrics[index].show = checked
|
||||
setMetricsOrder(newMetricsOrder)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{showGraphs &&
|
||||
<OrganizeCharts
|
||||
charts={metricsOrder.charts || []}
|
||||
metrics={props.metrics}
|
||||
close={props.close}
|
||||
onChange={(newCharts) => {
|
||||
const newMetricsOrder = clone(metricsOrder)
|
||||
newMetricsOrder.charts = newCharts
|
||||
setMetricsOrder(newMetricsOrder)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
<Button text="Save" className="ml-auto" onClick={() => {
|
||||
props.onSave(metricsOrder)
|
||||
props.close();
|
||||
}}/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface OrganizeDashboardChartsProps {
|
||||
charts: DashboardViewCharts
|
||||
metrics: Metric[]
|
||||
close: any
|
||||
onChange: (charts: DashboardViewCharts) => void
|
||||
}
|
||||
|
||||
function OrganizeCharts({metrics, charts, onChange, close}: OrganizeDashboardChartsProps) {
|
||||
|
||||
const addChart = () => {
|
||||
const newCharts = clone(charts)
|
||||
newCharts.push({
|
||||
name: "New chart",
|
||||
order: 1,
|
||||
metrics: []
|
||||
})
|
||||
onChange(newCharts)
|
||||
}
|
||||
|
||||
const deleteChart = (chartIndex: number) => {
|
||||
const newCharts = clone(charts)
|
||||
newCharts.splice(chartIndex, 1)
|
||||
onChange(newCharts)
|
||||
}
|
||||
|
||||
const setChart = (chartIndex: number, name: string, order: number) => {
|
||||
const newCharts = clone(charts)
|
||||
newCharts[chartIndex].name = name
|
||||
newCharts[chartIndex].order = order
|
||||
onChange(newCharts)
|
||||
}
|
||||
|
||||
const addMetricToChart = (chartIndex: number) => {
|
||||
const metric: Metric = metrics[0]
|
||||
const newCharts = clone(charts)
|
||||
newCharts[chartIndex].metrics.push({
|
||||
metricId: metric.id,
|
||||
color: "red",
|
||||
type: "line",
|
||||
alwaysUseLastValue: true
|
||||
})
|
||||
onChange(newCharts)
|
||||
}
|
||||
|
||||
const setMetricOnChart = (chartIndex: number, metricIndex: number, newMetric: {metricId: string, type: string, color: string, alwaysUseLastValue: boolean}) => {
|
||||
const newCharts = clone(charts)
|
||||
newCharts[chartIndex].metrics[metricIndex] = newMetric
|
||||
onChange(newCharts)
|
||||
}
|
||||
|
||||
const deleteMetricOnChart = (chartIndex: number, metricIndex: number) => {
|
||||
const newCharts = clone(charts)
|
||||
newCharts[chartIndex].metrics.splice(metricIndex, 1)
|
||||
onChange(newCharts)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="f-c gap-2">
|
||||
<Button text="Add chart" className="mx-auto cursor-pointer" onClick={addChart}/>
|
||||
{(charts || []).map((chart, chartIndex) => (
|
||||
<div key={chartIndex} className="f-c border border-neutral-500 p-2 rounded-md gap-2 mb-2">
|
||||
<div className="f-r items-center gap-2">
|
||||
<LineEdit className="w-32" value={chart.name} onChange={(event) => { setChart(chartIndex, event, charts[chartIndex].order) }}/>
|
||||
<div className={`${optionsCss} p-1`}>
|
||||
Order
|
||||
<LineEdit
|
||||
inputClassName="p-0 px-1"
|
||||
type="number"
|
||||
className="w-16"
|
||||
value={chart.order}
|
||||
onChange={(event) => {
|
||||
setChart(chartIndex, charts[chartIndex].name, parseInt(event))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
text="Add metric"
|
||||
onClick={() => { addMetricToChart(chartIndex) }}
|
||||
/>
|
||||
<Button
|
||||
text="Delete chart"
|
||||
color="danger"
|
||||
onClick={() => { setTimeout(() => deleteChart(chartIndex), 0) }}
|
||||
/>
|
||||
</div>
|
||||
<div className="f-c gap-2">
|
||||
{chart.metrics.map((selectedMetric, selectedMetricIndex) =>
|
||||
<div key={selectedMetricIndex} className="f-r items-center gap-2">
|
||||
<select onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, metricId: event.target.value}) }} className="bg-neutral-500 p-1 rounded-md">
|
||||
{metrics.map(metric =>
|
||||
<option
|
||||
value={metric.id}
|
||||
selected={metric.id === selectedMetric.metricId}
|
||||
>
|
||||
{metric.name}
|
||||
</option>)}
|
||||
</select>
|
||||
<select onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, type: event.target.value}) }} className="bg-neutral-500 p-1 rounded-md">
|
||||
<option
|
||||
value="line"
|
||||
selected={selectedMetric.type === "line"}
|
||||
>
|
||||
Line
|
||||
</option>
|
||||
<option
|
||||
value="bar"
|
||||
selected={selectedMetric.type === "bar"}
|
||||
>
|
||||
Bar
|
||||
</option>
|
||||
</select>
|
||||
<div className={`${optionsCss} p-1`}>
|
||||
Always use last value
|
||||
<input type="checkbox" checked={selectedMetric.alwaysUseLastValue}
|
||||
onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, alwaysUseLastValue: event.target.checked}) }}
|
||||
/>
|
||||
</div>
|
||||
<input type="color" value={selectedMetric.color}
|
||||
onChange={(event) => { setMetricOnChart(chartIndex, selectedMetricIndex, {...selectedMetric, color: event.target.value}) }}
|
||||
/>
|
||||
<Button color="danger" text="delete" onClick={() => {
|
||||
setTimeout(() => deleteMetricOnChart(chartIndex, selectedMetricIndex), 0)
|
||||
}}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
32
web-app/src/pages/projectPage/forms/TagForm.tsx
Normal file
32
web-app/src/pages/projectPage/forms/TagForm.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../../../components/Button";
|
||||
import LineEdit from "../../../components/Input";
|
||||
import {ApiKeyData} from "../../../core/ApiKey";
|
||||
import {TagData} from "../../../core/Tag";
|
||||
|
||||
interface TagFormProps {
|
||||
tag?: TagData
|
||||
close: any
|
||||
onCreate: any
|
||||
}
|
||||
|
||||
export function TagForm(props: TagFormProps) {
|
||||
const {register, handleSubmit} = useForm<TagData>({defaultValues: props.tag});
|
||||
|
||||
const createTag: SubmitHandler<TagData> = async (data) => {
|
||||
props.onCreate(data);
|
||||
props.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createTag)} className="f-c gap-2">
|
||||
<h2 className="text-2xl mb-4 text-left">
|
||||
{!props.tag && "Create new tag"}
|
||||
{props.tag && "Update tag"}
|
||||
</h2>
|
||||
<LineEdit label="Name" register={register("name")}/>
|
||||
<input type="color" className="w-full" {...register("color")}/>
|
||||
<Button type="submit" text={props.tag ? "Save" : "Create"}/>
|
||||
</form>
|
||||
)
|
||||
}
|
26
web-app/src/pages/projectPage/forms/apiKeyForm.tsx
Normal file
26
web-app/src/pages/projectPage/forms/apiKeyForm.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import {SubmitHandler, useForm} from "react-hook-form"
|
||||
import Button from "../../../components/Button";
|
||||
import LineEdit from "../../../components/Input";
|
||||
import {ApiKeyData} from "../../../core/ApiKey";
|
||||
|
||||
interface ApiKeyFormProps {
|
||||
close: any
|
||||
onCreate: any
|
||||
}
|
||||
|
||||
export function ApiKeyForm(props: ApiKeyFormProps) {
|
||||
const {register, handleSubmit} = useForm<Omit<ApiKeyData, "id">>();
|
||||
|
||||
const createApiKey: SubmitHandler<Omit<ApiKeyData, "id">> = async (data) => {
|
||||
props.onCreate(data);
|
||||
props.close();
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(createApiKey)} className="f-c gap-2">
|
||||
<h2 className="text-2xl mb-4 text-left">Generate a new API key</h2>
|
||||
<LineEdit label="Name" register={register("name")}/>
|
||||
<Button type="submit" text="Create"/>
|
||||
</form>
|
||||
)
|
||||
}
|
125
web-app/src/pages/projectPage/index.tsx
Normal file
125
web-app/src/pages/projectPage/index.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import {CogIcon} from "@heroicons/react/outline";
|
||||
import {useCallback, useEffect, useState} from "react";
|
||||
import { useParams} from "react-router-dom";
|
||||
import {useApi} from "../../api"
|
||||
import Card from "../../components/Card";
|
||||
import Chart from "../../components/Chart";
|
||||
import ProjectHeader from "../../components/ProjectHeader";
|
||||
import {Log} from "../../core/Log";
|
||||
import {Metric} from "../../core/Metric";
|
||||
import {Project} from "../../core/Project";
|
||||
import {View} from "../../core/View";
|
||||
import {useModal} from "../../services/ModalService";
|
||||
import {DashboardView, OrganizeDashboardForm} from "./forms/OrganizeDashboardForm";
|
||||
import LogsTab from "./LogsTab";
|
||||
import {MetricsPanel} from "./MetricsPanel";
|
||||
|
||||
export default function ProjectPage() {
|
||||
|
||||
const api = useApi();
|
||||
const modalService = useModal();
|
||||
const {projectId} = useParams();
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const [metrics, setMetrics] = useState<Metric[]>([]);
|
||||
const [dashboardConfig, setDashboardConfig] = useState<View<DashboardView> | null>(null)
|
||||
|
||||
const fetchProject = useCallback(async () => {
|
||||
const project = await api.fetchProject(projectId as string);
|
||||
setProject(project);
|
||||
const logs = await api.fetchProjectLogs(projectId as string);
|
||||
setLogs(logs);
|
||||
const metrics = await api.fetchProjectMetrics(projectId as string);
|
||||
setMetrics(metrics);
|
||||
const dashboardConfig = await api.fetchDashboardView(project.id)
|
||||
setDashboardConfig(dashboardConfig)
|
||||
}, [projectId, api])
|
||||
|
||||
useEffect(() => {fetchProject()}, [fetchProject])
|
||||
|
||||
if (!project) {
|
||||
return <div>Loading project...</div>
|
||||
}
|
||||
|
||||
const onSaveDashboard = async (dashboard: DashboardView) => {
|
||||
if (!dashboardConfig) {
|
||||
await api.createView(project.id, {
|
||||
provider: "Web",
|
||||
type: "dashboard",
|
||||
name: "dashboard",
|
||||
config: dashboard
|
||||
})
|
||||
return fetchProject();
|
||||
}
|
||||
await api.updateView(project.id, dashboardConfig, {name: "dashboard", config: dashboard});
|
||||
fetchProject();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 f-r h-full overflow-hidden">
|
||||
<div className="flex-1 f-c gap-2 overflow-hidden">
|
||||
<ProjectHeader
|
||||
project={project}
|
||||
middle={
|
||||
<div className="f-r justify-center items-center gap-2 px-1">
|
||||
<h3 className="text-xl">Dashboard</h3>
|
||||
<CogIcon
|
||||
className="w-4 h-4 cursor-pointer"
|
||||
onClick={async () => {modalService.addModal((close) =>
|
||||
<OrganizeDashboardForm
|
||||
metrics={metrics}
|
||||
dashboard={dashboardConfig}
|
||||
close={close}
|
||||
onSave={onSaveDashboard}
|
||||
/>
|
||||
)}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<MetricsPanel metrics={metrics} dashboardConfig={dashboardConfig}/>
|
||||
<div className="flex-1 f-r gap-2 overflow-hidden">
|
||||
<LogsTab
|
||||
className="flex-1 overflow-scroll"
|
||||
project={project}
|
||||
logs={logs}
|
||||
/>
|
||||
<div className="flex-1 f-c gap-2 overflow-scroll">
|
||||
{(dashboardConfig?.config.charts || []).map(chart => {
|
||||
const data = chart.metrics.map(line => {
|
||||
const metric = metrics.find(metricc => metricc.id === line.metricId)
|
||||
return {
|
||||
name: metric?.name || "default",
|
||||
type: line.type,
|
||||
color: line.color !== '' ? line.color : "red",
|
||||
alwaysUseLastValue: line.alwaysUseLastValue,
|
||||
data: (metric?.history || []).map(metricHistory => {
|
||||
return {value: metricHistory.value, date: metricHistory.date}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="flex-1 f-c">
|
||||
<div className="f-r justify-center gap-8">
|
||||
{chart.metrics.map(metricChart => {
|
||||
const metric = metrics.find(metric => metric.id === metricChart.metricId)
|
||||
return <div className="f-r items-center gap-2" style={{color: metricChart.color}}><span className="text-3xl">{metric?.currentValue}</span> {metric?.name}</div>
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Chart charts={data}/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
54
web-app/src/pages/projects.tsx
Normal file
54
web-app/src/pages/projects.tsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import {useCallback, useEffect, useState} from "react";
|
||||
import {Link} from "react-router-dom";
|
||||
import {useApi} from "../api"
|
||||
import Button from "../components/Button";
|
||||
import {ProjectSideBar} from "../components/ProjectSideBar";
|
||||
import {Project} from "../core/Project";
|
||||
import {CreateProjectForm} from "../forms/createProjectForm";
|
||||
import {useModal} from "../services/ModalService";
|
||||
|
||||
export default function Projects() {
|
||||
|
||||
const api = useApi();
|
||||
const modalService = useModal();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
const fetchProjects = useCallback(async () => {
|
||||
const projects = await api.fetchProjects();
|
||||
setProjects(projects);
|
||||
}, [api])
|
||||
|
||||
const createProject = useCallback(async (data: any) => {
|
||||
await api.createProject(data);
|
||||
fetchProjects();
|
||||
}, [api, fetchProjects])
|
||||
|
||||
const showCreateProjectForm = useCallback(async () => {
|
||||
modalService.addModal((close) => <CreateProjectForm close={close} onCreate={createProject}/>)
|
||||
}, [createProject, modalService])
|
||||
|
||||
useEffect(() => {fetchProjects()}, [fetchProjects])
|
||||
|
||||
return (
|
||||
<div className="h-screen flex-1 f-r">
|
||||
<ProjectSideBar/>
|
||||
<div className="flex-1 f-c gap-4 px-4">
|
||||
<div className="f-r justify-between border-b border-b-neutral-500 py-4">
|
||||
<h1 className="text-4xl">Projects</h1>
|
||||
<Button text="Create project" onClick={showCreateProjectForm}/>
|
||||
</div>
|
||||
<div className="f-r flex-wrap gap-4">
|
||||
{projects.map(project => (
|
||||
<Link
|
||||
key={project.id}
|
||||
to={`/projects/${project.id}`}
|
||||
className="flex-1 f-c gap-4 bg-neutral-600 p-4 text-left rounded-md cursor-pointer hover:bg-neutral-500 duration-150">
|
||||
{project.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
1
web-app/src/react-app-env.d.ts
vendored
Normal file
1
web-app/src/react-app-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/// <reference types="react-scripts" />
|
15
web-app/src/reportWebVitals.ts
Normal file
15
web-app/src/reportWebVitals.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
82
web-app/src/services/ModalService.tsx
Normal file
82
web-app/src/services/ModalService.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
import {createContext, useCallback, useContext, useState} from "react";
|
||||
import Button from "../components/Button";
|
||||
import Modal from "../components/Modal";
|
||||
|
||||
type cb = (close: any) => JSX.Element
|
||||
|
||||
export interface ModalContextProps {
|
||||
modals: JSX.Element[]
|
||||
addModal: (cb: cb) => void
|
||||
info: (description: string) => void
|
||||
error: (description: string) => void
|
||||
confirmation: (description: string, onConfirmation: () => void) => void
|
||||
closeLast: () => void
|
||||
}
|
||||
|
||||
export const ModalContext = createContext<ModalContextProps>({} as ModalContextProps)
|
||||
const value: ModalContextProps = {} as ModalContextProps // See comments below
|
||||
|
||||
export function ModalServiceProvider({children}: any) {
|
||||
|
||||
const [modals, setModals] = useState<JSX.Element[]>([])
|
||||
|
||||
const closeCurrentModal = () => {
|
||||
modals.splice(-1, 1)
|
||||
setModals([...modals])
|
||||
}
|
||||
|
||||
const addModal = (cb: cb) => {
|
||||
const content = cb(() => {value.closeLast()});
|
||||
setModals([...modals, content])
|
||||
}
|
||||
|
||||
const error = (description: string) => {
|
||||
addModal((close) => <p>{description}</p>)
|
||||
}
|
||||
|
||||
const confirmation = (description: string, onConfirmation: () => void) => {
|
||||
addModal((close) =>
|
||||
<div className="f-c gap-4">
|
||||
<p className="text-left">{description}</p>
|
||||
<div className="f-r gap-2">
|
||||
<Button text="Delete" color="danger" className="ml-auto" onClick={() => {
|
||||
onConfirmation()
|
||||
close()
|
||||
}}/>
|
||||
<Button text="Cancel" onClick={close}/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
// Because this function is designed to be used with nested modals, we must ensure that the "root" object is the
|
||||
// same and wont change so the updated functions (addModal) is called
|
||||
value.modals = modals;
|
||||
value.addModal = addModal;
|
||||
value.info = error;
|
||||
value.error = error;
|
||||
value.confirmation = confirmation;
|
||||
value.closeLast = closeCurrentModal
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={value}>
|
||||
{children}
|
||||
{modals.map((modal, index) =>
|
||||
<Modal
|
||||
key={index}
|
||||
customId={`modal-${index}`}
|
||||
show={modals.length > 0}
|
||||
onClose={index === modals.length - 1 ? () => {closeCurrentModal()} : () => {}}
|
||||
ignoreInputs={index != modals.length - 1}
|
||||
>
|
||||
{modal}
|
||||
</Modal>
|
||||
)}
|
||||
</ModalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useModal() {
|
||||
return useContext(ModalContext);
|
||||
}
|
5
web-app/src/setupTests.ts
Normal file
5
web-app/src/setupTests.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
3
web-app/src/utils/copyToClipboard.tsx
Normal file
3
web-app/src/utils/copyToClipboard.tsx
Normal file
|
@ -0,0 +1,3 @@
|
|||
export function copyToClipboard(text: string) {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
5
web-app/src/utils/formatTimestamp.ts
Normal file
5
web-app/src/utils/formatTimestamp.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import moment from "moment";
|
||||
|
||||
export function formatTimestamp(timestamp: number): string {
|
||||
return moment(timestamp).format("YYYY-MM-DD hh:mm a")
|
||||
}
|
17
web-app/tailwind.config.js
Normal file
17
web-app/tailwind.config.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
app: {
|
||||
primary: "#36CCCC"
|
||||
},
|
||||
primary: "#36AAAA",
|
||||
"primary-hover": "#368888",
|
||||
"primary-active": "#367777",
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
}
|
26
web-app/tsconfig.json
Normal file
26
web-app/tsconfig.json
Normal file
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue