mirror of https://github.com/grafana/grafana
Crawler: use existing render service to generate dashboard thumbnails (#43515)
Co-authored-by: Artur Wierzbicki <artur@arturwierzbicki.com>pull/43871/head
parent
cc9e70be5c
commit
b404aae9c3
@ -0,0 +1,249 @@ |
|||||||
|
package thumbs |
||||||
|
|
||||||
|
import ( |
||||||
|
"context" |
||||||
|
"encoding/json" |
||||||
|
"errors" |
||||||
|
"fmt" |
||||||
|
"math/rand" |
||||||
|
"os" |
||||||
|
"strings" |
||||||
|
"sync" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/grafana/grafana/pkg/bus" |
||||||
|
"github.com/grafana/grafana/pkg/models" |
||||||
|
"github.com/grafana/grafana/pkg/services/live" |
||||||
|
"github.com/grafana/grafana/pkg/services/rendering" |
||||||
|
"github.com/grafana/grafana/pkg/services/search" |
||||||
|
) |
||||||
|
|
||||||
|
type dashItem struct { |
||||||
|
uid string |
||||||
|
url string |
||||||
|
} |
||||||
|
|
||||||
|
type simpleCrawler struct { |
||||||
|
screenshotsFolder string |
||||||
|
renderService rendering.Service |
||||||
|
threadCount int |
||||||
|
|
||||||
|
glive *live.GrafanaLive |
||||||
|
mode CrawlerMode |
||||||
|
opts rendering.Opts |
||||||
|
status crawlStatus |
||||||
|
queue []dashItem |
||||||
|
mu sync.Mutex |
||||||
|
} |
||||||
|
|
||||||
|
func newSimpleCrawler(folder string, renderService rendering.Service, gl *live.GrafanaLive) dashRenderer { |
||||||
|
c := &simpleCrawler{ |
||||||
|
screenshotsFolder: folder, |
||||||
|
renderService: renderService, |
||||||
|
threadCount: 5, |
||||||
|
glive: gl, |
||||||
|
status: crawlStatus{ |
||||||
|
State: "init", |
||||||
|
Complete: 0, |
||||||
|
Queue: 0, |
||||||
|
}, |
||||||
|
queue: make([]dashItem, 0), |
||||||
|
} |
||||||
|
c.broadcastStatus() |
||||||
|
return c |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) next() *dashItem { |
||||||
|
if len(r.queue) < 1 { |
||||||
|
return nil |
||||||
|
} |
||||||
|
r.mu.Lock() |
||||||
|
defer r.mu.Unlock() |
||||||
|
|
||||||
|
v := r.queue[0] |
||||||
|
r.queue = r.queue[1:] |
||||||
|
return &v |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) broadcastStatus() { |
||||||
|
s, err := r.Status() |
||||||
|
if err != nil { |
||||||
|
tlog.Warn("error reading status") |
||||||
|
return |
||||||
|
} |
||||||
|
msg, err := json.Marshal(s) |
||||||
|
if err != nil { |
||||||
|
tlog.Warn("error making message") |
||||||
|
return |
||||||
|
} |
||||||
|
err = r.glive.Publish(r.opts.OrgID, "grafana/broadcast/crawler", msg) |
||||||
|
if err != nil { |
||||||
|
tlog.Warn("error Publish message") |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) GetPreview(req *previewRequest) *previewResponse { |
||||||
|
p := getFilePath(r.screenshotsFolder, req) |
||||||
|
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { |
||||||
|
return r.queueRender(p, req) |
||||||
|
} |
||||||
|
|
||||||
|
return &previewResponse{ |
||||||
|
Path: p, |
||||||
|
Code: 200, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) queueRender(p string, req *previewRequest) *previewResponse { |
||||||
|
go func() { |
||||||
|
fmt.Printf("todo? queue") |
||||||
|
}() |
||||||
|
|
||||||
|
return &previewResponse{ |
||||||
|
Code: 202, |
||||||
|
Path: p, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) Start(c *models.ReqContext, mode CrawlerMode, theme rendering.Theme) (crawlStatus, error) { |
||||||
|
if r.status.State == "running" { |
||||||
|
tlog.Info("already running") |
||||||
|
return r.Status() |
||||||
|
} |
||||||
|
|
||||||
|
r.mu.Lock() |
||||||
|
defer r.mu.Unlock() |
||||||
|
|
||||||
|
searchQuery := search.Query{ |
||||||
|
SignedInUser: c.SignedInUser, |
||||||
|
OrgId: c.OrgId, |
||||||
|
} |
||||||
|
|
||||||
|
err := bus.Dispatch(context.Background(), &searchQuery) |
||||||
|
if err != nil { |
||||||
|
return crawlStatus{}, err |
||||||
|
} |
||||||
|
|
||||||
|
queue := make([]dashItem, 0, len(searchQuery.Result)) |
||||||
|
for _, v := range searchQuery.Result { |
||||||
|
if v.Type == search.DashHitDB { |
||||||
|
queue = append(queue, dashItem{ |
||||||
|
uid: v.UID, |
||||||
|
url: v.URL, |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
rand.Seed(time.Now().UnixNano()) |
||||||
|
rand.Shuffle(len(queue), func(i, j int) { queue[i], queue[j] = queue[j], queue[i] }) |
||||||
|
|
||||||
|
r.mode = mode |
||||||
|
r.opts = rendering.Opts{ |
||||||
|
OrgID: c.OrgId, |
||||||
|
UserID: c.UserId, |
||||||
|
OrgRole: c.OrgRole, |
||||||
|
Theme: theme, |
||||||
|
ConcurrentLimit: 10, |
||||||
|
} |
||||||
|
r.queue = queue |
||||||
|
r.status = crawlStatus{ |
||||||
|
Started: time.Now(), |
||||||
|
State: "running", |
||||||
|
Complete: 0, |
||||||
|
} |
||||||
|
r.broadcastStatus() |
||||||
|
|
||||||
|
// create a pool of workers
|
||||||
|
for i := 0; i < r.threadCount; i++ { |
||||||
|
go r.walk() |
||||||
|
|
||||||
|
// wait 1/2 second before starting a new thread
|
||||||
|
time.Sleep(500 * time.Millisecond) |
||||||
|
} |
||||||
|
|
||||||
|
r.broadcastStatus() |
||||||
|
return r.Status() |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) Stop() (crawlStatus, error) { |
||||||
|
// cheap hack!
|
||||||
|
if r.status.State == "running" { |
||||||
|
r.status.State = "stopping" |
||||||
|
} |
||||||
|
return r.Status() |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) Status() (crawlStatus, error) { |
||||||
|
status := crawlStatus{ |
||||||
|
State: r.status.State, |
||||||
|
Started: r.status.Started, |
||||||
|
Complete: r.status.Complete, |
||||||
|
Errors: r.status.Errors, |
||||||
|
Queue: len(r.queue), |
||||||
|
Last: r.status.Last, |
||||||
|
} |
||||||
|
return status, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (r *simpleCrawler) walk() { |
||||||
|
for { |
||||||
|
if r.status.State == "stopping" { |
||||||
|
break |
||||||
|
} |
||||||
|
|
||||||
|
item := r.next() |
||||||
|
if item == nil { |
||||||
|
break |
||||||
|
} |
||||||
|
|
||||||
|
tlog.Info("GET THUMBNAIL", "url", item.url) |
||||||
|
|
||||||
|
// Hack (for now) pick a URL that will render
|
||||||
|
panelURL := strings.TrimPrefix(item.url, "/") + "?kiosk" |
||||||
|
res, err := r.renderService.Render(context.Background(), rendering.Opts{ |
||||||
|
Width: 320, |
||||||
|
Height: 240, |
||||||
|
Path: panelURL, |
||||||
|
OrgID: r.opts.OrgID, |
||||||
|
UserID: r.opts.UserID, |
||||||
|
ConcurrentLimit: r.opts.ConcurrentLimit, |
||||||
|
OrgRole: r.opts.OrgRole, |
||||||
|
Theme: r.opts.Theme, |
||||||
|
Timeout: 10 * time.Second, |
||||||
|
DeviceScaleFactor: -5, // negative numbers will render larger then scale down
|
||||||
|
}) |
||||||
|
if err != nil { |
||||||
|
tlog.Warn("error getting image", "err", err) |
||||||
|
r.status.Errors++ |
||||||
|
} else if res.FilePath == "" { |
||||||
|
tlog.Warn("error getting image... no response") |
||||||
|
r.status.Errors++ |
||||||
|
} else if strings.Contains(res.FilePath, "public/img") { |
||||||
|
tlog.Warn("error getting image... internal result", "img", res.FilePath) |
||||||
|
r.status.Errors++ |
||||||
|
} else { |
||||||
|
p := getFilePath(r.screenshotsFolder, &previewRequest{ |
||||||
|
UID: item.uid, |
||||||
|
OrgID: r.opts.OrgID, |
||||||
|
Theme: r.opts.Theme, |
||||||
|
Size: PreviewSizeThumb, |
||||||
|
}) |
||||||
|
err = os.Rename(res.FilePath, p) |
||||||
|
if err != nil { |
||||||
|
r.status.Errors++ |
||||||
|
tlog.Warn("error moving image", "err", err) |
||||||
|
} else { |
||||||
|
r.status.Complete++ |
||||||
|
tlog.Info("saved thumbnail", "img", item.url) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
time.Sleep(5 * time.Second) |
||||||
|
r.status.Last = time.Now() |
||||||
|
r.broadcastStatus() |
||||||
|
} |
||||||
|
|
||||||
|
r.status.State = "stopped" |
||||||
|
r.status.Finished = time.Now() |
||||||
|
r.broadcastStatus() |
||||||
|
} |
||||||
@ -1,72 +0,0 @@ |
|||||||
package thumbs |
|
||||||
|
|
||||||
import ( |
|
||||||
"bytes" |
|
||||||
"encoding/json" |
|
||||||
"errors" |
|
||||||
"fmt" |
|
||||||
"io/ioutil" |
|
||||||
"net/http" |
|
||||||
"os" |
|
||||||
) |
|
||||||
|
|
||||||
type renderHttp struct { |
|
||||||
crawlerURL string |
|
||||||
config crawConfig |
|
||||||
} |
|
||||||
|
|
||||||
func newRenderHttp(crawlerURL string, cfg crawConfig) dashRenderer { |
|
||||||
return &renderHttp{ |
|
||||||
crawlerURL: crawlerURL, |
|
||||||
config: cfg, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (r *renderHttp) GetPreview(req *previewRequest) *previewResponse { |
|
||||||
p := getFilePath(r.config.ScreenshotsFolder, req) |
|
||||||
if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) { |
|
||||||
return r.queueRender(p, req) |
|
||||||
} |
|
||||||
|
|
||||||
return &previewResponse{ |
|
||||||
Path: p, |
|
||||||
Code: 200, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
func (r *renderHttp) CrawlerCmd(cfg *crawlCmd) (json.RawMessage, error) { |
|
||||||
cmd := r.config |
|
||||||
cmd.crawlCmd = *cfg |
|
||||||
|
|
||||||
jsonData, err := json.Marshal(cmd) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
request, err := http.NewRequest("POST", r.crawlerURL, bytes.NewBuffer(jsonData)) |
|
||||||
if err != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
request.Header.Set("Content-Type", "application/json; charset=UTF-8") |
|
||||||
|
|
||||||
client := &http.Client{} |
|
||||||
response, error := client.Do(request) |
|
||||||
if error != nil { |
|
||||||
return nil, err |
|
||||||
} |
|
||||||
defer func() { |
|
||||||
_ = response.Body.Close() |
|
||||||
}() |
|
||||||
|
|
||||||
return ioutil.ReadAll(response.Body) |
|
||||||
} |
|
||||||
|
|
||||||
func (r *renderHttp) queueRender(p string, req *previewRequest) *previewResponse { |
|
||||||
go func() { |
|
||||||
fmt.Printf("todo? queue") |
|
||||||
}() |
|
||||||
|
|
||||||
return &previewResponse{ |
|
||||||
Code: 202, |
|
||||||
Path: p, |
|
||||||
} |
|
||||||
} |
|
||||||
@ -0,0 +1,61 @@ |
|||||||
|
import React, { useState } from 'react'; |
||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { Button, CodeEditor, Modal, useTheme2 } from '@grafana/ui'; |
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { getBackendSrv, config } from '@grafana/runtime'; |
||||||
|
|
||||||
|
export const CrawlerStartButton = () => { |
||||||
|
const styles = getStyles(useTheme2()); |
||||||
|
const [open, setOpen] = useState(false); |
||||||
|
const [body, setBody] = useState({ |
||||||
|
mode: 'thumbs', |
||||||
|
theme: config.theme2.isLight ? 'light' : 'dark', |
||||||
|
}); |
||||||
|
const onDismiss = () => setOpen(false); |
||||||
|
const doStart = () => { |
||||||
|
getBackendSrv() |
||||||
|
.post('/api/admin/crawler/start', body) |
||||||
|
.then((v) => { |
||||||
|
console.log('GOT', v); |
||||||
|
onDismiss(); |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<> |
||||||
|
<Modal title={'Start crawler'} isOpen={open} onDismiss={onDismiss}> |
||||||
|
<div className={styles.wrap}> |
||||||
|
<CodeEditor |
||||||
|
height={200} |
||||||
|
value={JSON.stringify(body, null, 2) ?? ''} |
||||||
|
showLineNumbers={false} |
||||||
|
readOnly={false} |
||||||
|
language="json" |
||||||
|
showMiniMap={false} |
||||||
|
onBlur={(text: string) => { |
||||||
|
setBody(JSON.parse(text)); // force JSON?
|
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<Modal.ButtonRow> |
||||||
|
<Button onClick={doStart}>Start</Button> |
||||||
|
<Button variant="secondary" onClick={onDismiss}> |
||||||
|
Cancel |
||||||
|
</Button> |
||||||
|
</Modal.ButtonRow> |
||||||
|
</Modal> |
||||||
|
|
||||||
|
<Button onClick={() => setOpen(true)} variant="primary"> |
||||||
|
Start |
||||||
|
</Button> |
||||||
|
</> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
wrap: css` |
||||||
|
border: 2px solid #111; |
||||||
|
`,
|
||||||
|
}; |
||||||
|
}; |
||||||
@ -0,0 +1,79 @@ |
|||||||
|
import React, { useEffect, useState } from 'react'; |
||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { Button, useTheme2 } from '@grafana/ui'; |
||||||
|
import { GrafanaTheme2, isLiveChannelMessageEvent, isLiveChannelStatusEvent, LiveChannelScope } from '@grafana/data'; |
||||||
|
import { getBackendSrv, getGrafanaLiveSrv } from '@grafana/runtime'; |
||||||
|
import { CrawlerStartButton } from './CrawlerStartButton'; |
||||||
|
|
||||||
|
interface CrawlerStatusMessage { |
||||||
|
state: string; |
||||||
|
started: string; |
||||||
|
finished: string; |
||||||
|
complete: number; |
||||||
|
queue: number; |
||||||
|
last: string; |
||||||
|
} |
||||||
|
|
||||||
|
export const CrawlerStatus = () => { |
||||||
|
const styles = getStyles(useTheme2()); |
||||||
|
const [status, setStatus] = useState<CrawlerStatusMessage>(); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const subscription = getGrafanaLiveSrv() |
||||||
|
.getStream<CrawlerStatusMessage>({ |
||||||
|
scope: LiveChannelScope.Grafana, |
||||||
|
namespace: 'broadcast', |
||||||
|
path: 'crawler', |
||||||
|
}) |
||||||
|
.subscribe({ |
||||||
|
next: (evt) => { |
||||||
|
if (isLiveChannelMessageEvent(evt)) { |
||||||
|
setStatus(evt.message); |
||||||
|
} else if (isLiveChannelStatusEvent(evt)) { |
||||||
|
setStatus(evt.message); |
||||||
|
} |
||||||
|
}, |
||||||
|
}); |
||||||
|
return () => { |
||||||
|
subscription.unsubscribe(); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
if (!status) { |
||||||
|
return ( |
||||||
|
<div className={styles.wrap}> |
||||||
|
No status (never run) |
||||||
|
<br /> |
||||||
|
<CrawlerStartButton /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.wrap}> |
||||||
|
<pre>{JSON.stringify(status, null, 2)}</pre> |
||||||
|
{status.state !== 'running' && <CrawlerStartButton />} |
||||||
|
{status.state !== 'stopped' && ( |
||||||
|
<Button |
||||||
|
variant="secondary" |
||||||
|
onClick={() => { |
||||||
|
getBackendSrv().post('/api/admin/crawler/stop'); |
||||||
|
}} |
||||||
|
> |
||||||
|
Stop |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
}; |
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => { |
||||||
|
return { |
||||||
|
wrap: css` |
||||||
|
border: 4px solid red; |
||||||
|
`,
|
||||||
|
running: css` |
||||||
|
border: 4px solid green; |
||||||
|
`,
|
||||||
|
}; |
||||||
|
}; |
||||||
Loading…
Reference in new issue