├── .env.example
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── netlify.toml
├── src
├── setupProxy.js
├── index.js
├── App.css
├── index.css
├── lambda
│ └── graphql.js
└── App.js
├── README.md
├── .gitignore
├── LICENSE
└── package.json
/.env.example:
--------------------------------------------------------------------------------
1 | ALGOLIA_APP_ID=
2 | ALGOLIA_API_KEY=
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yarnpkg/search-indexer-dashboard/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | command = "yarn build"
3 | functions = "lambda"
4 | publish = "build"
5 |
6 | [[redirects]]
7 | from = "https://yarn-search-status.netlify.com/*"
8 | to = "https://search-status.yarnpkg.com/:splat"
9 | status = 301
10 | force = true
11 |
--------------------------------------------------------------------------------
/src/setupProxy.js:
--------------------------------------------------------------------------------
1 | const proxy = require('http-proxy-middleware');
2 |
3 | module.exports = function(app) {
4 | app.use(proxy('/.netlify/functions/', {
5 | target: 'http://localhost:9000/',
6 | "pathRewrite": {
7 | "^/\\.netlify/functions": ""
8 | }
9 | }));
10 | };
11 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Yarn Status",
3 | "name": "Yarn Search Status",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Yarn search indexer status page
2 |
3 | https://yarn-search-status.netlify.com
4 |
5 | This is a small dashboard (and graphql endpoint) for seeing the status of the [npm -> Algolia indexer](https://github.com/algolia/npm-search).
6 |
7 | ## Setting this up yourself
8 |
9 | - If you want this to be deployed: use Netlify
10 | - set the env variables in a file `.env` copied from `.env.example`
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 | /lambda
12 |
13 | /.netlify
14 |
15 | # misc
16 | .DS_Store
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { GraphQLClient, ClientContext } from 'graphql-hooks';
4 |
5 | import './index.css';
6 | import App from './App';
7 |
8 | const client = new GraphQLClient({
9 | url: '/.netlify/functions/graphql',
10 | });
11 |
12 | ReactDOM.render(
13 |
14 |
15 | ,
16 | document.getElementById('root')
17 | );
18 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .table {
2 | display: grid;
3 | grid-template-columns: 4fr 4fr 4fr;
4 | grid-row-gap: 3em;
5 | }
6 |
7 | .table-item {
8 | --col-span: 'not overriden';
9 | text-align: center;
10 | display: grid;
11 | grid-template-columns: 1fr;
12 | grid-column: 1 / span var(--col-span);
13 | }
14 |
15 | .mega {
16 | font-size: 3em;
17 | font-weight: 700;
18 | }
19 |
20 | .massive {
21 | font-size: 5em;
22 | font-weight: 700;
23 | }
24 |
25 | .medium {
26 | font-size: 1.5em;
27 | font-weight: 500;
28 | }
29 |
30 | .title {
31 | text-align: center;
32 | font-size: 1.5em;
33 | margin-bottom: 2em;
34 | }
35 |
36 | .raw-data {
37 | position: absolute;
38 | bottom: -2em;
39 | background: white;
40 | }
41 |
42 | .header {
43 | font-size: 2em;
44 | display: flex;
45 | justify-content: space-between;
46 | }
47 |
48 | .info-icon {
49 | vertical-align: bottom;
50 | }
51 |
52 | .info-icon svg {
53 | vertical-align: bottom;
54 | }
55 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Yarn
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-lambda",
3 | "version": "0.4.0",
4 | "private": true,
5 | "dependencies": {
6 | "algoliasearch": "^3.32.0",
7 | "apollo-server-lambda": "^2.5.0",
8 | "bufferutil": "^4.0.1",
9 | "dotenv": "^6.2.0",
10 | "encoding": "^0.1.12",
11 | "got": "^9.6.0",
12 | "graphql": "^14.1.1",
13 | "graphql-hooks": "^3.2.2",
14 | "ms": "^2.1.1",
15 | "react": "^16.8.3",
16 | "react-dom": "^16.8.3",
17 | "react-scripts": "^2.1.3",
18 | "utf-8-validate": "^5.0.2"
19 | },
20 | "scripts": {
21 | "start": "run-p start:**",
22 | "start:app": "react-scripts start",
23 | "start:lambda": "netlify-lambda serve src/lambda",
24 | "build": "run-p build:**",
25 | "build:app": "react-scripts build",
26 | "build:lambda": "netlify-lambda build src/lambda",
27 | "test": "react-scripts test",
28 | "eject": "react-scripts eject"
29 | },
30 | "eslintConfig": {
31 | "extends": "react-app"
32 | },
33 | "browserslist": [
34 | ">0.2%",
35 | "not dead",
36 | "not ie <= 11",
37 | "not op_mini all"
38 | ],
39 | "devDependencies": {
40 | "@babel/plugin-transform-object-assign": "^7.0.0",
41 | "babel-loader": "8.0.4",
42 | "http-proxy-middleware": "^0.19.0",
43 | "netlify-lambda": "^1.4.13",
44 | "npm-run-all": "^4.1.5"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --monospace: Menlo, monospace;
3 | --sans-serif: -apple-system, BlinkMacSystemFont, sans-serif;
4 | }
5 |
6 | body {
7 | font-family: var(--sans-serif);
8 | color: rgb(34, 39, 39);
9 | }
10 |
11 | .error {
12 | color: red;
13 | background-color: pink;
14 | border: 0.2em solid currentColor;
15 | padding: 1em;
16 | margin: 1em;
17 | font-family: var(--monospace);
18 | }
19 |
20 | code,
21 | pre {
22 | font-family: var(--monospace);
23 | }
24 |
25 | svg {
26 | width: 1em;
27 | height: 1em;
28 | }
29 |
30 | .inaccessible-link {
31 | color: currentColor;
32 | }
33 |
34 | .inaccessible-button {
35 | font-size: 1em;
36 | height: 1em;
37 | background: none;
38 | border: none;
39 | padding: 0;
40 | }
41 |
42 | .inaccessible-button:active {
43 | color: currentColor;
44 | }
45 |
46 | .spin {
47 | animation: spin 1s linear infinite;
48 | }
49 |
50 | @keyframes spin {
51 | 100% {
52 | transform: rotate(-360deg);
53 | }
54 | }
55 |
56 | progress[value] {
57 | appearance: none;
58 | width: 100%;
59 | height: 0.5em;
60 | }
61 |
62 | progress[value]::-webkit-progress-bar {
63 | background: none;
64 | }
65 |
66 | progress[value]::-webkit-progress-inner-element {
67 | border: 1px solid currentColor;
68 | }
69 |
70 | progress[value]::-webkit-progress-value {
71 | background-color: currentColor;
72 | }
73 |
74 | button {
75 | color: currentColor;
76 | }
77 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
14 |
15 |
16 |
25 | Yarn search status
26 |
27 |
28 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/lambda/graphql.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 | const { ApolloServer, gql } = require('apollo-server-lambda');
3 | const algoliasearch = require('algoliasearch');
4 | const got = require('got');
5 |
6 | const { ALGOLIA_APP_ID, ALGOLIA_API_KEY } = process.env;
7 |
8 | const searchClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_API_KEY);
9 |
10 | // The GraphQL schema
11 | const typeDefs = gql`
12 | # Status of an Algolia index
13 | type IndexStatus {
14 | nbHits: Int
15 | }
16 |
17 | type IndexesStatus {
18 | main: IndexStatus
19 | bootstrap: IndexStatus
20 | }
21 |
22 | # Current stage of the indexer
23 | enum IndexerStage {
24 | bootstrap
25 | replicate
26 | watch
27 | }
28 |
29 | # Status of the indexer process
30 | type IndexerStatus {
31 | # Last npm sequence indexed
32 | seq: Int
33 | # Is bootstrap sequence completed
34 | bootstrapDone: Boolean
35 | # Last package indexed in bootstrap
36 | bootstrapLastId: String
37 | # Date last finished bootstrap
38 | bootstrapLastDone: String
39 | # stage
40 | stage: IndexerStage
41 | }
42 |
43 | # status of the npm api
44 | type NpmStatus {
45 | # current npm sequence
46 | seq: Int
47 | # number of packages in the registry
48 | nbDocs: Int
49 | }
50 |
51 | type BuildJobs {
52 | main: Int
53 | bootstrap: Int
54 | }
55 |
56 | # Status on the whole application
57 | type ApplicationStatus {
58 | building: BuildJobs
59 | nbRecords: Int
60 | dataSize: Int
61 | fileSize: Int
62 | nbIndexes: Int
63 | nbBuildingIndexes: Int
64 | oldestJob: Int
65 | todayOperations: Int
66 | yesterdayOperations: Int
67 | todayIndexingOperations: Int
68 | yesterdayIndexingOperations: Int
69 | }
70 |
71 | type Query {
72 | applicationStatus(
73 | mainIndexName: String
74 | bootstrapIndexName: String
75 | ): ApplicationStatus
76 | indexStatus(
77 | mainIndexName: String
78 | bootstrapIndexName: String
79 | ): IndexesStatus
80 | indexerStatus(mainIndexName: String): IndexerStatus
81 | npmStatus: NpmStatus
82 | }
83 | `;
84 |
85 | const resolvers = {
86 | Query: {
87 | applicationStatus(
88 | _parent,
89 | {
90 | mainIndexName = 'npm-search',
91 | bootstrapIndexName = 'npm-search-bootstrap',
92 | }
93 | ) {
94 | return searchClient
95 | ._jsonRequest({
96 | method: 'GET',
97 | url: '/1/indexes/*/stats',
98 | hostType: 'write',
99 | })
100 | .then(({ building, ...otherKeys }) => ({
101 | building: {
102 | main: building[mainIndexName] || 0,
103 | bootstrap: building[bootstrapIndexName] || 0,
104 | },
105 | ...otherKeys,
106 | }));
107 | },
108 | indexStatus(
109 | _parent,
110 | {
111 | mainIndexName = 'npm-search',
112 | bootstrapIndexName = 'npm-search-bootstrap',
113 | }
114 | ) {
115 | return searchClient
116 | .search([
117 | {
118 | indexName: mainIndexName,
119 | params: {
120 | hitsPerPage: 1,
121 | attributesToRetrieve: [],
122 | attributesToHighlight: [],
123 | },
124 | },
125 | {
126 | indexName: bootstrapIndexName,
127 | params: {
128 | hitsPerPage: 1,
129 | attributesToRetrieve: [],
130 | attributesToHighlight: [],
131 | },
132 | },
133 | ])
134 | .then(({ results: [main, bootstrap] }) => {
135 | return { main, bootstrap };
136 | });
137 | },
138 | indexerStatus(_parent, { mainIndexName = 'npm-search' }) {
139 | return searchClient
140 | .initIndex(mainIndexName)
141 | .getSettings()
142 | .then(
143 | ({
144 | userData: {
145 | seq,
146 | bootstrapDone,
147 | bootstrapLastId,
148 | bootstrapLastDone,
149 | stage,
150 | },
151 | }) => ({
152 | seq,
153 | bootstrapDone,
154 | bootstrapLastId,
155 | bootstrapLastDone: (bootstrapLastDone || 0).toString(),
156 | stage,
157 | })
158 | );
159 | },
160 | npmStatus: () =>
161 | got('https://replicate.npmjs.com', { json: true }).then(
162 | ({ body: { doc_count: nbDocs, update_seq: seq } }) => ({
163 | nbDocs,
164 | seq,
165 | })
166 | ),
167 | },
168 | };
169 |
170 | const ALL_ITEMS_QUERY = `
171 | {
172 | applicationStatus(
173 | mainIndexName: "npm-search"
174 | bootstrapIndexName: "npm-search-bootstrap"
175 | ) {
176 | building {
177 | main
178 | bootstrap
179 | }
180 | }
181 |
182 | indexStatus(
183 | mainIndexName: "npm-search"
184 | bootstrapIndexName: "npm-search-bootstrap"
185 | ) {
186 | main {
187 | nbHits
188 | }
189 | bootstrap {
190 | nbHits
191 | }
192 | }
193 |
194 | indexerStatus(mainIndexName: "npm-search") {
195 | seq
196 | stage
197 | bootstrapLastDone
198 | bootstrapDone
199 | bootstrapLastId
200 | }
201 |
202 | npmStatus {
203 | seq
204 | nbDocs
205 | }
206 | }
207 | `;
208 |
209 | const server = new ApolloServer({
210 | typeDefs,
211 | resolvers,
212 | introspection: true,
213 | playground: {
214 | settings: {
215 | 'editor.theme': 'light',
216 | },
217 | endpoint: '/.netlify/functions/graphql',
218 | tabs: [
219 | {
220 | endpoint: '/.netlify/functions/graphql',
221 | name: 'all items',
222 | query: ALL_ITEMS_QUERY,
223 | },
224 | ],
225 | },
226 | });
227 |
228 | exports.handler = server.createHandler();
229 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useQuery } from 'graphql-hooks';
3 | import ms from 'ms';
4 | import './App.css';
5 |
6 | const ALL_ITEMS_QUERY = `
7 | query AllInformation($mainIndexName: String, $bootstrapIndexName: String) {
8 | applicationStatus(
9 | mainIndexName: $mainIndexName
10 | bootstrapIndexName: $bootstrapIndexName
11 | ) {
12 | building {
13 | main
14 | bootstrap
15 | }
16 | }
17 |
18 | indexStatus(
19 | mainIndexName: $mainIndexName
20 | bootstrapIndexName: $bootstrapIndexName
21 | ) {
22 | main {
23 | nbHits
24 | }
25 | bootstrap {
26 | nbHits
27 | }
28 | }
29 |
30 | indexerStatus(mainIndexName: $mainIndexName) {
31 | seq
32 | stage
33 | bootstrapLastDone
34 | bootstrapDone
35 | bootstrapLastId
36 | }
37 |
38 | npmStatus {
39 | seq
40 | nbDocs
41 | }
42 | }
43 | `;
44 |
45 | const Info = ({ as: Tag = 'span', className = '', ...props }) => (
46 |
47 |
53 |
54 | );
55 |
56 | export default function App() {
57 | const {
58 | data,
59 | error,
60 | loading,
61 | refetch,
62 | fetchError,
63 | httpError,
64 | graphQLErrors,
65 | } = useQuery(ALL_ITEMS_QUERY, {
66 | variables: Object.fromEntries(new URLSearchParams(window.location.search)),
67 | });
68 |
69 | return (
70 | <>
71 |
72 |
80 |
86 |
87 |
88 | {error && (
89 |
94 | )}
95 |
96 | {!error && data && }
97 | >
98 | );
99 | }
100 |
101 | function Visualization({ data }) {
102 | const {
103 | applicationStatus: { building },
104 | npmStatus,
105 | indexStatus: { main, bootstrap },
106 | indexerStatus: {
107 | bootstrapLastId,
108 | stage,
109 | bootstrapLastDone: bootstrapLastDoneString,
110 | seq,
111 | },
112 | } = data;
113 |
114 | const bootstrapLastDone = Number(bootstrapLastDoneString);
115 | const nextBootstrap = bootstrapLastDone + ms('2 weeks');
116 | const timeSinceLastBootstrap = new Date().getTime() - bootstrapLastDone;
117 |
118 | return (
119 |
120 |
121 | stage: {stage}
122 |
123 |
124 | {stage === 'bootstrap' && (
125 |
143 | )}
144 | {(stage === 'watch' || stage === 'replicate') && (
145 |
165 | )}
166 |
167 |
168 |
169 |
170 | Raw data
171 |
172 | If you are interested, there's also a{' '}
173 | GraphQL playground{' '}
174 | available for this data.
175 |
176 | {JSON.stringify(data, null, 2)}
177 |
178 |
179 |
180 | );
181 | }
182 |
183 | const BootstrapStage = ({ lastProcessed, progress, packages, jobs }) => (
184 | <>
185 |
195 |
196 |
197 |
198 | bootstrap sequence
199 |
200 |
201 | {(progress.bootstrap || 0).toLocaleString('fr-FR')}
202 |
203 |
204 |
205 |
206 | sequence difference
207 |
208 |
209 |
{progress.diff}
210 |
211 |
212 |
213 | npm sequence
214 |
215 |
216 | {(progress.npm || 0).toLocaleString('fr-FR')}
217 |
218 |
219 |
220 |
221 |
222 | # packages in bootstrap
223 |
224 |
225 | {(packages.bootstrap || 0).toLocaleString('fr-FR')}
226 |
227 |
228 |
229 |
230 | # packages difference
231 |
232 |
233 |
234 | {(packages.diff || 0).toLocaleString('fr-FR')}
235 |
236 |
237 |
238 |
239 | # packages in npm
240 |
241 |
242 | {(packages.npm || 0).toLocaleString('fr-FR')}
243 |
244 |
245 |
246 |
247 |
248 | jobs processing
249 |
250 |
251 |
252 | {(jobs.processing || 0).toLocaleString('fr-FR')}
253 |
254 |
255 |
256 |
257 |
258 | last processed
259 |
260 |
261 |
262 | {lastProcessed.id}
263 |
264 |
265 | >
266 | );
267 |
268 | const dayFormat = new Intl.DateTimeFormat('en-FR', {
269 | weekday: 'long',
270 | month: 'long',
271 | day: 'numeric',
272 | hour: 'numeric',
273 | minute: 'numeric',
274 | });
275 |
276 | const WatchStage = ({ bootstrap, sequence, packages, jobs }) => (
277 | <>
278 |
279 |
last bootstrap
280 |
281 | {(bootstrap.last || 0).toLocaleDateString('nl-BE')}
282 |
283 |
{(bootstrap.last || 0).toLocaleTimeString('nl-BE')}
284 |
285 |
286 |
287 | time ago
288 |
289 |
290 |
{bootstrap.diff}
291 |
292 |
293 |
next bootstrap
294 |
295 | {(bootstrap.next || 0).toLocaleDateString('nl-BE')}
296 |
297 |
{(bootstrap.next || 0).toLocaleTimeString('nl-BE')}
298 |
299 |
300 |
301 |
302 | npm-search sequence
303 |
304 |
305 | {(sequence.main || 0).toLocaleString('fr-FR')}
306 |
307 |
308 |
309 |
310 | sequence difference
311 |
312 |
313 |
314 | {(sequence.diff || 0).toLocaleString('fr-FR')}
315 |
316 |
317 |
318 |
319 | npm sequence
320 |
321 |
322 | {(sequence.npm || 0).toLocaleString('fr-FR')}
323 |
324 |
325 |
326 |
327 |
328 | # packages in npm-search
329 |
330 |
331 | {(packages.main || 0).toLocaleString('fr-FR')}
332 |
333 |
334 |
335 |
336 | # packages difference
337 |
338 |
339 |
340 | {(packages.diff || 0).toLocaleString('fr-FR')}
341 |
342 |
343 |
344 |
345 | # packages in npm
346 |
347 |
348 | {(packages.npm || 0).toLocaleString('fr-FR')}
349 |
350 |
351 |
352 |
353 |
354 |
355 | jobs processing
356 |
357 |
358 |
359 | {(jobs.processing || 0).toLocaleString('fr-FR')}
360 |
361 |
362 |
363 | >
364 | );
365 |
366 | function Error({ httpError, fetchError, graphQLErrors }) {
367 | if (httpError) {
368 | return {httpError.statusText}
;
369 | }
370 | if (graphQLErrors) {
371 | return (
372 |
373 | GraphQL Errors:
374 | {graphQLErrors.map(error => (
375 |
376 | ))}
377 |
378 | );
379 | }
380 |
381 | return (
382 |
383 | A network error occurred:
384 |
385 |
386 | );
387 | }
388 |
389 | function ErrorMessage({ message, ...more }) {
390 | return (
391 |
392 | {message}
393 |
394 | Full error
395 | {JSON.stringify(more, null, 2)}
396 |
397 |
398 | );
399 | }
400 |
--------------------------------------------------------------------------------