init git
commit
acb3b0b80f
@ -0,0 +1,3 @@
|
|||||||
|
VITE_ASANA_API_URI=
|
||||||
|
VITE_ASANA_PROJECT_ID=
|
||||||
|
VITE_ACCESS_TOKEN=
|
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
@ -0,0 +1,34 @@
|
|||||||
|
# One.com Asana Incidents Viz
|
||||||
|
|
||||||
|
This is a single page application written using React and Tailwind css, and uses
|
||||||
|
(Vite JS)[https://vitejs.dev] as the bundler/dev server.
|
||||||
|
|
||||||
|
This project heavily relies on the Asana v1.0 API and thus any changes to the API
|
||||||
|
will result in a non-functioning viz tool.
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
1. Clone this repo
|
||||||
|
2. Install node dependencies: `npm install`
|
||||||
|
3. Copy the `.env.example` file as `.env.local` and fill in the required values.
|
||||||
|
4. Run the local dev server: `npm run dev`
|
||||||
|
|
||||||
|
```
|
||||||
|
Note: Asana API URI can be set as https://app.asana.com/api/1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
1. Clone this repo
|
||||||
|
2. Install node dependencies: `npm install`
|
||||||
|
3. Either follow step 3 from the development setup OR provide the same variables to the build context
|
||||||
|
4. Run the build command: `npm run build`
|
||||||
|
5. Deploy the `dist` directory to any web server of your choice
|
||||||
|
|
||||||
|
```
|
||||||
|
Note: Make sure you don't copy the node_modules folder if you are making a zip.
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under MIT.
|
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Incidents and Maintenance Viz | one.com</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "one-com-viz",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "Ayush Mukherjee <me@ayushm.com>",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"serve": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"chart.js": "^3.2.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"react": "^17.0.0",
|
||||||
|
"react-chartjs-2": "^3.0.3",
|
||||||
|
"react-dom": "^17.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/forms": "^0.3.2",
|
||||||
|
"@vitejs/plugin-react-refresh": "^1.3.1",
|
||||||
|
"autoprefixer": "^10.2.5",
|
||||||
|
"postcss": "^8.2.15",
|
||||||
|
"tailwindcss": "^2.1.4",
|
||||||
|
"vite": "^2.3.3"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import _ from 'lodash'
|
||||||
|
import { getFields, getTasks } from './services/AsanaService'
|
||||||
|
import IncidentsChart from './components/IncidentsChart'
|
||||||
|
import ProductsChart from './components/ProductsChart'
|
||||||
|
import ServicesChart from './components/ServicesChart'
|
||||||
|
import ServiceInfo from './components/ServiceInfo'
|
||||||
|
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [fields, setFields] = useState([])
|
||||||
|
const [tasks, setTasks] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [statusMsg, setStatusMsg] = useState('Loading...')
|
||||||
|
|
||||||
|
useEffect(async () => {
|
||||||
|
// get information from api
|
||||||
|
setStatusMsg('Getting fields information...')
|
||||||
|
await getFields(setFields)
|
||||||
|
|
||||||
|
setStatusMsg('Fetching records...')
|
||||||
|
await getTasks(setTasks)
|
||||||
|
|
||||||
|
// set loading to false
|
||||||
|
setLoading(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div className="container mx-auto pt-4 px-4 min-h-screen overflow-x-hidden">
|
||||||
|
{
|
||||||
|
loading === true ? statusMsg : <>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="my-2">
|
||||||
|
<h1 className="font-bold text-2xl text-center">Incidents Graph</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<IncidentsChart atasks={tasks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col">
|
||||||
|
<div className="my-2">
|
||||||
|
<h1 className="font-bold text-2xl text-center">Impacted Products Graph</h1>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ProductsChart fields={fields} tasks={tasks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-col">
|
||||||
|
<div className="my-2">
|
||||||
|
<h1 className="font-bold text-2xl text-center">Service Impact Graphs</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap md:flex-nowrap">
|
||||||
|
<ServicesChart fields={fields[0]} idx={0} tasks={tasks} />
|
||||||
|
<ServicesChart fields={fields[1]} idx={1} tasks={tasks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col my-4">
|
||||||
|
<div className="mb-2">
|
||||||
|
<p className="text-xl">Service Impact Information</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<ServiceInfo fields={fields} tasks={tasks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
@ -0,0 +1,58 @@
|
|||||||
|
import React, { memo, useEffect } from 'react'
|
||||||
|
import array from 'lodash/array'
|
||||||
|
import { Line } from 'react-chartjs-2'
|
||||||
|
import { getRandomColor } from '../utils'
|
||||||
|
|
||||||
|
const IncidentsChart = ({ atasks }) => {
|
||||||
|
|
||||||
|
let tasks = [...atasks]
|
||||||
|
array.reverse(tasks)
|
||||||
|
const monthNames = ["January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
const dates = []
|
||||||
|
const counts = []
|
||||||
|
const bgColors = []
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
const d = new Date(t.created_at)
|
||||||
|
const dstr = monthNames[d.getMonth()] + ' ' + d.getFullYear()
|
||||||
|
if (dates.indexOf(dstr) === -1) {
|
||||||
|
dates.push(dstr)
|
||||||
|
counts.push(1)
|
||||||
|
bgColors.push(getRandomColor())
|
||||||
|
} else {
|
||||||
|
counts[dates.indexOf(dstr)] += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div className="my-2 h-64 w-full">
|
||||||
|
<Line
|
||||||
|
data={{
|
||||||
|
labels: dates,
|
||||||
|
datasets: [{
|
||||||
|
data: counts,
|
||||||
|
borderColor: 'rgba(29, 78, 216, 1)'
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
yAxis: {
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(IncidentsChart)
|
@ -0,0 +1,46 @@
|
|||||||
|
import React, { memo } from 'react'
|
||||||
|
import { Pie } from 'react-chartjs-2'
|
||||||
|
import { getRandomColor } from '../utils'
|
||||||
|
|
||||||
|
const ProductsChart = ({ fields, tasks }) => {
|
||||||
|
|
||||||
|
const products = fields[2].options
|
||||||
|
const counts = []
|
||||||
|
const bgColors = []
|
||||||
|
|
||||||
|
products.forEach(() => {
|
||||||
|
counts.push(0)
|
||||||
|
bgColors.push(getRandomColor())
|
||||||
|
})
|
||||||
|
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
if (t.custom_fields[2].enum_value) {
|
||||||
|
counts[products.indexOf(t.custom_fields[2].enum_value.name)] += 1
|
||||||
|
} else if (t.custom_fields[3].enum_value) {
|
||||||
|
counts[products.indexOf(t.custom_fields[3].enum_value.name)] += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div className="my-2 h-64 w-full">
|
||||||
|
<Pie
|
||||||
|
data={{
|
||||||
|
labels: products,
|
||||||
|
datasets: [{
|
||||||
|
data: counts,
|
||||||
|
backgroundColor: bgColors,
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ProductsChart)
|
@ -0,0 +1,79 @@
|
|||||||
|
import React, { memo, useState, useEffect } from 'react'
|
||||||
|
import array from 'lodash/array'
|
||||||
|
import ServiceProductChart from './ServiceProductChart'
|
||||||
|
|
||||||
|
const ServiceInfo = ({ fields, tasks }) => {
|
||||||
|
const [services, setServices] = useState([])
|
||||||
|
|
||||||
|
const [selection, setSelection] = useState(null)
|
||||||
|
|
||||||
|
const [fTasks, setTasks] = useState([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServices(array.union(fields[0].options, fields[1].options))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filterTasksByService = (service) => {
|
||||||
|
setTasks(tasks.filter((t) =>
|
||||||
|
t.custom_fields[0].enum_value !== null && t.custom_fields[0].enum_value.name === service
|
||||||
|
|| t.custom_fields[1].enum_value !== null && t.custom_fields[1].enum_value.name === service
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelect = (e) => {
|
||||||
|
setSelection(e.currentTarget.value)
|
||||||
|
filterTasksByService(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asssuming Asana structure doesn't change
|
||||||
|
const getProductsAndDetails = (t) => {
|
||||||
|
const prod1 = t.custom_fields[2].enum_value?.name
|
||||||
|
const prod2 = t.custom_fields[3].enum_value?.name
|
||||||
|
|
||||||
|
if (prod1 && prod2) {
|
||||||
|
return prod1 + ', ' + prod2
|
||||||
|
} else if (prod1) {
|
||||||
|
return prod1
|
||||||
|
} else if (prod2) {
|
||||||
|
return prod2
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'None'
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<div className="flex flex-col w-96">
|
||||||
|
<label htmlFor="serviceSelect">Select Service:</label>
|
||||||
|
<select onChange={setSelect} defaultValue="0">
|
||||||
|
<option value="0" disabled>Select an option...</option>
|
||||||
|
{ services.map((s, idx) => (
|
||||||
|
<option key={idx} value={s}> {s} </option>
|
||||||
|
)) }
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{ selection !== null ? <>
|
||||||
|
<div className="mt-2 px-2 py-4 flex flex-col md:flex-row justify-center">
|
||||||
|
{ fTasks.length !== 0 ?
|
||||||
|
<ol className="list-decimal list-inside">
|
||||||
|
{ fTasks.map((t, idx) => (
|
||||||
|
<li className="px-2 my-4" key={idx}>
|
||||||
|
{t.name}
|
||||||
|
<ul className="list-disc list-inside px-2 py-2">
|
||||||
|
<li><strong className="font-bold">Reason: </strong> {t.custom_fields[4].text_value || 'None'} </li>
|
||||||
|
<li><strong className="font-bold">Impacted Products: </strong> {
|
||||||
|
getProductsAndDetails(t)
|
||||||
|
} </li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
)) }
|
||||||
|
</ol>
|
||||||
|
: 'None' }
|
||||||
|
<div className="w-full h-64 md:w-1/2">
|
||||||
|
<ServiceProductChart atasks={fTasks} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</> : '' }
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(ServiceInfo)
|
@ -0,0 +1,69 @@
|
|||||||
|
import React, { memo } from 'react'
|
||||||
|
import array from 'lodash/array'
|
||||||
|
import { Line } from 'react-chartjs-2'
|
||||||
|
import { getRandomColor } from '../utils'
|
||||||
|
|
||||||
|
const ServiceProductChart = ({atasks}) => {
|
||||||
|
|
||||||
|
let tasks = [...atasks]
|
||||||
|
const monthNames = ["January", "February", "March", "April", "May", "June",
|
||||||
|
"July", "August", "September", "October", "November", "December"
|
||||||
|
];
|
||||||
|
const labels = []
|
||||||
|
const prods = []
|
||||||
|
const data = []
|
||||||
|
const datasets = []
|
||||||
|
|
||||||
|
array.reverse(tasks)
|
||||||
|
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
const d = new Date(t.created_at)
|
||||||
|
const dstr = d.getDate() + ' ' + monthNames[d.getMonth()] + ' ' + d.getFullYear()
|
||||||
|
labels.push(dstr)
|
||||||
|
if (t.custom_fields[2].display_value !== null && prods.indexOf(t.custom_fields[2].display_value) === -1) {
|
||||||
|
prods.push(t.custom_fields[2].display_value)
|
||||||
|
} else if (t.custom_fields[3].display_value !== null && prods.indexOf(t.custom_fields[3].display_value) === -1) {
|
||||||
|
prods.push(t.custom_fields[3].display_value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
prods.forEach(() => data.push([]))
|
||||||
|
|
||||||
|
if (prods.length > 0) {
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
prods.forEach((p, idx) => {
|
||||||
|
if (t.custom_fields[2].display_value === p || t.custom_fields[3].display_value === p) {
|
||||||
|
data[idx].push(1)
|
||||||
|
} else {
|
||||||
|
data[idx].push(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
prods.forEach((p, idx) => {
|
||||||
|
datasets.push({
|
||||||
|
label: p,
|
||||||
|
data: data[idx],
|
||||||
|
borderColor: getRandomColor(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return <Line
|
||||||
|
data={{
|
||||||
|
labels,
|
||||||
|
datasets,
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
yAxis: {
|
||||||
|
min: 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ServiceProductChart
|
@ -0,0 +1,50 @@
|
|||||||
|
import React, { memo } from 'react'
|
||||||
|
import { Bar } from 'react-chartjs-2'
|
||||||
|
import { getRandomColor } from '../utils'
|
||||||
|
|
||||||
|
const Chart = ({ fields, idx, tasks }) => {
|
||||||
|
|
||||||
|
const labels = fields.options
|
||||||
|
const data = []
|
||||||
|
const bgColors = []
|
||||||
|
|
||||||
|
labels.forEach((l) => {
|
||||||
|
let count = 0
|
||||||
|
tasks.forEach((t) => {
|
||||||
|
if (t.custom_fields[idx].enum_value != null && t.custom_fields[idx].enum_value.name === l) {
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
data.push(count)
|
||||||
|
bgColors.push(getRandomColor())
|
||||||
|
})
|
||||||
|
|
||||||
|
return <div className="my-2 h-96 w-full md:w-1/2">
|
||||||
|
<Bar
|
||||||
|
data={{
|
||||||
|
labels,
|
||||||
|
datasets: [{
|
||||||
|
data,
|
||||||
|
backgroundColor: bgColors,
|
||||||
|
}],
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: fields.name,
|
||||||
|
font: {
|
||||||
|
size: '20'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Chart)
|
@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
document.getElementById('root')
|
||||||
|
)
|
@ -0,0 +1,43 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const getFields = async (setFields) => {
|
||||||
|
const fArr = [];
|
||||||
|
const res = await axios.get(`${import.meta.env.VITE_ASANA_API_URI}/projects/${import.meta.env.VITE_ASANA_PROJECT_ID}`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
res.data.data.custom_field_settings.forEach((a) => {
|
||||||
|
const f = {
|
||||||
|
id: a.gid,
|
||||||
|
name: a.custom_field.name,
|
||||||
|
type: a.custom_field.type,
|
||||||
|
};
|
||||||
|
const opts = [];
|
||||||
|
if (a.custom_field.type == 'enum') {
|
||||||
|
a.custom_field.enum_options.forEach((o) => {
|
||||||
|
opts.push(o.name);
|
||||||
|
});
|
||||||
|
f.options = opts;
|
||||||
|
}
|
||||||
|
fArr.push(f);
|
||||||
|
});
|
||||||
|
setFields(fArr);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTasks = async (setTasks) => {
|
||||||
|
const res2 = await axios.get(`${import.meta.env.VITE_ASANA_API_URI}/tasks`, {
|
||||||
|
params: {
|
||||||
|
section: '1199915951089746',
|
||||||
|
opt_fields: 'name,created_at,custom_fields',
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${import.meta.env.VITE_ACCESS_TOKEN}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const data = res2.data.data;
|
||||||
|
data.sort((a, b) => {
|
||||||
|
return new Date(b.created_at) - new Date(a.created_at);
|
||||||
|
});
|
||||||
|
setTasks(data);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
export const getRandomColor = () => {
|
||||||
|
let letters = '0123456789ABCDEF'.split('');
|
||||||
|
let color = '#';
|
||||||
|
for (let i = 0; i < 6; i++ ) {
|
||||||
|
color += letters[Math.floor(Math.random() * 16)];
|
||||||
|
}
|
||||||
|
return color;
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
purge: ['./index.html', './src/**/*.{js,jsx}'],
|
||||||
|
mode: 'jit',
|
||||||
|
darkMode: false,
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/forms'),
|
||||||
|
],
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import reactRefresh from '@vitejs/plugin-react-refresh'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [reactRefresh()]
|
||||||
|
})
|
Loading…
Reference in New Issue