mirror of
git://f0xx.org/android_cast
synced 2026-07-29 04:57:40 +03:00
stage 1
This commit is contained in:
@@ -101,7 +101,7 @@ Green **`master`** plus integration branch **`next`**. See [docs/GIT_FLOW.md](do
|
||||
|
||||
## PDF helpers
|
||||
|
||||
Generate the GraphVis decision memo PDF (from `tmp/GRAFANA_vs_others_graphvis_pivot.md` source):
|
||||
Generate the GraphVis decision memo PDF (from `docs/GRAFANA_vs_others_graphvis_pivot.md` source):
|
||||
|
||||
```bash
|
||||
bash scripts/build-graphvis-pdf.sh
|
||||
@@ -109,4 +109,4 @@ bash scripts/build-graphvis-pdf.sh
|
||||
|
||||
Output:
|
||||
|
||||
`tmp/GRAFANA_vs_others_graphvis_pivot.pdf`
|
||||
`docs/GRAFANA_vs_others_graphvis_pivot.pdf`
|
||||
|
||||
@@ -45,7 +45,9 @@ public class CastSettings implements Serializable {
|
||||
/** Future Speex (UI disabled until integrated). */
|
||||
SPEEX,
|
||||
/** Future Opus with in-band FEC (UI disabled until integrated). */
|
||||
OPUS
|
||||
OPUS,
|
||||
/** Explicit AAC passthrough wrapper for debug comparisons. */
|
||||
PASSTHROUGH_DEBUG
|
||||
}
|
||||
|
||||
public enum AdjustmentPreset {
|
||||
|
||||
@@ -604,6 +604,8 @@ public final class CastSettingsBinder {
|
||||
return activity.getString(R.string.codec_speex);
|
||||
case OPUS:
|
||||
return activity.getString(R.string.codec_opus);
|
||||
case PASSTHROUGH_DEBUG:
|
||||
return activity.getString(R.string.codec_audio_passthrough_debug);
|
||||
case AUTO:
|
||||
default:
|
||||
return activity.getString(R.string.codec_audio_auto);
|
||||
|
||||
@@ -16,6 +16,7 @@ import com.foxx.androidcast.CastTuningEngine;
|
||||
import com.foxx.androidcast.media.CodecNegotiator;
|
||||
import com.foxx.androidcast.media.codec.CodecSessionRegistry;
|
||||
import com.foxx.androidcast.media.codec.PassthroughCodecPolicy;
|
||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||
import com.foxx.androidcast.network.control.NetworkStatsSnapshot;
|
||||
import com.foxx.androidcast.network.diag.DiagPingMetrics;
|
||||
import com.foxx.androidcast.receiver.StreamMetrics;
|
||||
@@ -393,9 +394,17 @@ public final class CastDiagnosticsFormatter {
|
||||
if (prefs != null && prefs.isAudioRequested()) {
|
||||
CastSettings.AudioCodec ac = prefs.getAudioCodec();
|
||||
if (ac == CastSettings.AudioCodec.OPUS) {
|
||||
html.append(" · <font color='").append(COLOR_OK).append("'>Opus in-band FEC</font>");
|
||||
if (NativeCodecBridge.isOpusAvailable()) {
|
||||
html.append(" · <font color='").append(COLOR_OK).append("'>Opus in-band FEC</font>");
|
||||
} else {
|
||||
html.append(" · <font color='").append(COLOR_WARN).append("'>Opus fallback AAC</font>");
|
||||
}
|
||||
} else if (ac == CastSettings.AudioCodec.SPEEX) {
|
||||
html.append(" · <font color='").append(COLOR_OK).append("'>Speex FEC</font>");
|
||||
if (NativeCodecBridge.isSpeexAvailable()) {
|
||||
html.append(" · <font color='").append(COLOR_OK).append("'>Speex FEC</font>");
|
||||
} else {
|
||||
html.append(" · <font color='").append(COLOR_WARN).append("'>Speex fallback AAC</font>");
|
||||
}
|
||||
} else {
|
||||
html.append(" · AAC-LC (MediaCodec)");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ package com.foxx.androidcast.diagnostics;
|
||||
* Digest: SHA256 1c6f25ad871cb71788ed6fd1817d4d59d22d5ef6638704c9483937a2a9c15977
|
||||
**********************************************************************/
|
||||
import com.foxx.androidcast.CastSettings;
|
||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||
|
||||
/** Human-readable names for diagnostics (no bare AUTO enum tokens). */
|
||||
public final class CastDiagnosticsLabels {
|
||||
@@ -137,9 +138,13 @@ public final class CastDiagnosticsLabels {
|
||||
case AAC:
|
||||
return "AAC-LC (forced)";
|
||||
case SPEEX:
|
||||
return "Speex (stub → AAC)";
|
||||
return NativeCodecBridge.isSpeexAvailable()
|
||||
? "Speex (native)" : "Speex (fallback AAC)";
|
||||
case OPUS:
|
||||
return "Opus + in-band FEC (stub → AAC)";
|
||||
return NativeCodecBridge.isOpusAvailable()
|
||||
? "Opus + in-band FEC (native)" : "Opus (fallback AAC)";
|
||||
case PASSTHROUGH_DEBUG:
|
||||
return "Passthrough AAC (debug)";
|
||||
case AUTO:
|
||||
default:
|
||||
return "AUTO (AAC via MediaCodec)";
|
||||
|
||||
@@ -248,10 +248,13 @@ public final class CodecPriorityCatalog {
|
||||
|
||||
public static CodecTier tierForAudioPreference(CastSettings.AudioCodec preference) {
|
||||
if (preference == CastSettings.AudioCodec.SPEEX) {
|
||||
return CodecTier.PASSTHROUGH;
|
||||
return NativeCodecBridge.isSpeexAvailable() ? CodecTier.SOFTWARE : CodecTier.PASSTHROUGH;
|
||||
}
|
||||
if (preference == CastSettings.AudioCodec.OPUS) {
|
||||
return CodecTier.SOFTWARE;
|
||||
return NativeCodecBridge.isOpusAvailable() ? CodecTier.SOFTWARE : CodecTier.PASSTHROUGH;
|
||||
}
|
||||
if (preference == CastSettings.AudioCodec.PASSTHROUGH_DEBUG) {
|
||||
return CodecTier.PASSTHROUGH;
|
||||
}
|
||||
return CodecTier.HARDWARE;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ public enum CodecPriorityId {
|
||||
return OPUS;
|
||||
case SPEEX:
|
||||
return SPEEX;
|
||||
case PASSTHROUGH_DEBUG:
|
||||
return PCM;
|
||||
case AUTO:
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -14,8 +14,11 @@ package com.foxx.androidcast.media.codec;
|
||||
/** Implementation backing negotiated / configured audio. */
|
||||
public enum AudioCodecBackend {
|
||||
MEDIA_CODEC_AAC("MediaCodec AAC-LC"),
|
||||
SPEEX_NATIVE("Speex native"),
|
||||
OPUS_NATIVE("Opus native"),
|
||||
SPEEX_STUB("Speex (stub → AAC passthrough)"),
|
||||
OPUS_STUB("Opus (stub → AAC passthrough)"),
|
||||
PASSTHROUGH_DEBUG("Passthrough AAC (debug)"),
|
||||
DISABLED("off"),
|
||||
UNKNOWN("?");
|
||||
|
||||
|
||||
@@ -21,7 +21,9 @@ public final class AudioCodecFactory {
|
||||
public static AudioEncoderSink createEncoder(CastSettings.AudioCodec preference) {
|
||||
CodecSessionRegistry.setAudioPreference(preference);
|
||||
AudioCodecBackend backend = PassthroughCodecPolicy.audioBackendFor(preference, true);
|
||||
if (backend == AudioCodecBackend.SPEEX_STUB || backend == AudioCodecBackend.OPUS_STUB) {
|
||||
if (backend == AudioCodecBackend.SPEEX_STUB
|
||||
|| backend == AudioCodecBackend.OPUS_STUB
|
||||
|| backend == AudioCodecBackend.PASSTHROUGH_DEBUG) {
|
||||
return new PassthroughAudioEncoderSink(new AudioEncoder(), preference);
|
||||
}
|
||||
return new AudioEncoder();
|
||||
|
||||
@@ -31,6 +31,8 @@ final class PassthroughAudioEncoderSink implements AudioEncoderSink {
|
||||
CodecSessionRegistry.setAudioBackend(AudioCodecBackend.OPUS_STUB);
|
||||
} else if (preference == CastSettings.AudioCodec.SPEEX) {
|
||||
CodecSessionRegistry.setAudioBackend(AudioCodecBackend.SPEEX_STUB);
|
||||
} else if (preference == CastSettings.AudioCodec.PASSTHROUGH_DEBUG) {
|
||||
CodecSessionRegistry.setAudioBackend(AudioCodecBackend.PASSTHROUGH_DEBUG);
|
||||
}
|
||||
delegate.prepare(callback);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,12 @@ public final class PassthroughCodecPolicy {
|
||||
}
|
||||
switch (preference) {
|
||||
case SPEEX:
|
||||
return NativeCodecBridge.isSpeexAvailable()
|
||||
? CastSettings.AudioCodec.SPEEX : CastSettings.AudioCodec.AUTO;
|
||||
case OPUS:
|
||||
return NativeCodecBridge.isOpusAvailable()
|
||||
? CastSettings.AudioCodec.OPUS : CastSettings.AudioCodec.AUTO;
|
||||
case PASSTHROUGH_DEBUG:
|
||||
return CastSettings.AudioCodec.AUTO;
|
||||
default:
|
||||
return preference;
|
||||
@@ -70,10 +75,15 @@ public final class PassthroughCodecPolicy {
|
||||
return AudioCodecBackend.DISABLED;
|
||||
}
|
||||
if (preference == CastSettings.AudioCodec.SPEEX) {
|
||||
return AudioCodecBackend.SPEEX_STUB;
|
||||
return NativeCodecBridge.isSpeexAvailable()
|
||||
? AudioCodecBackend.SPEEX_NATIVE : AudioCodecBackend.SPEEX_STUB;
|
||||
}
|
||||
if (preference == CastSettings.AudioCodec.OPUS) {
|
||||
return AudioCodecBackend.OPUS_STUB;
|
||||
return NativeCodecBridge.isOpusAvailable()
|
||||
? AudioCodecBackend.OPUS_NATIVE : AudioCodecBackend.OPUS_STUB;
|
||||
}
|
||||
if (preference == CastSettings.AudioCodec.PASSTHROUGH_DEBUG) {
|
||||
return AudioCodecBackend.PASSTHROUGH_DEBUG;
|
||||
}
|
||||
return AudioCodecBackend.MEDIA_CODEC_AAC;
|
||||
}
|
||||
@@ -150,6 +160,8 @@ public final class PassthroughCodecPolicy {
|
||||
return "Opus";
|
||||
case AAC:
|
||||
return "AAC";
|
||||
case PASSTHROUGH_DEBUG:
|
||||
return "Passthrough (debug)";
|
||||
case AUTO:
|
||||
default:
|
||||
return "AUTO (AAC)";
|
||||
|
||||
@@ -13,6 +13,7 @@ package com.foxx.androidcast.network;
|
||||
**********************************************************************/
|
||||
import android.media.MediaFormat;
|
||||
|
||||
import com.foxx.androidcast.media.codec.jni.NativeCodecBridge;
|
||||
import com.foxx.androidcast.sender.CodecCatalog;
|
||||
|
||||
import java.util.List;
|
||||
@@ -28,6 +29,8 @@ public final class CastCodecFlags {
|
||||
public static final int AUDIO_UNKNOWN = 0;
|
||||
public static final int AUDIO_PCM = 1;
|
||||
public static final int AUDIO_AAC = 2;
|
||||
public static final int AUDIO_OPUS = 4;
|
||||
public static final int AUDIO_SPEEX = 8;
|
||||
|
||||
private CastCodecFlags() {}
|
||||
|
||||
@@ -59,6 +62,13 @@ public final class CastCodecFlags {
|
||||
}
|
||||
|
||||
public static int localAudioMask() {
|
||||
return AUDIO_AAC | AUDIO_PCM;
|
||||
int mask = AUDIO_AAC | AUDIO_PCM;
|
||||
if (NativeCodecBridge.isOpusAvailable()) {
|
||||
mask |= AUDIO_OPUS;
|
||||
}
|
||||
if (NativeCodecBridge.isSpeexAvailable()) {
|
||||
mask |= AUDIO_SPEEX;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ public final class CastProtocol {
|
||||
dos.writeInt(settings.getCaptureMode().ordinal());
|
||||
dos.writeInt(settings.getStreamProtection().ordinal());
|
||||
dos.writeInt(settings.getReceiverDisplayResolution().ordinal());
|
||||
dos.writeInt(settings.getAudioCodec().ordinal());
|
||||
dos.flush();
|
||||
return bos.toByteArray();
|
||||
}
|
||||
@@ -147,6 +148,10 @@ public final class CastProtocol {
|
||||
readEnum(CastSettings.ReceiverDisplayResolution.values(), dis.readInt(),
|
||||
CastSettings.ReceiverDisplayResolution.AUTO_FIT));
|
||||
}
|
||||
if (dis.available() > 0) {
|
||||
settings.setAudioCodec(readEnum(CastSettings.AudioCodec.values(), dis.readInt(),
|
||||
CastSettings.AudioCodec.AUTO));
|
||||
}
|
||||
} else {
|
||||
dis.reset();
|
||||
settings.setTransport(dis.readUTF());
|
||||
|
||||
@@ -88,10 +88,13 @@ public final class CodecCatalog {
|
||||
|
||||
public static List<AudioCodecChoice> audioCodecChoicesForUi(CastSettings settings) {
|
||||
List<AudioCodecChoice> out = new ArrayList<>();
|
||||
boolean opus = NativeCodecBridge.isOpusAvailable();
|
||||
boolean speex = NativeCodecBridge.isSpeexAvailable();
|
||||
out.add(new AudioCodecChoice(CastSettings.AudioCodec.AUTO, true, 0));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.AAC, true, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.OPUS, true, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, true, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.OPUS, opus, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.SPEEX, speex, settings));
|
||||
out.add(scoredAudio(CastSettings.AudioCodec.PASSTHROUGH_DEBUG, true, settings));
|
||||
sortAudioChoices(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -226,12 +226,13 @@
|
||||
<string name="dev_reload_codecs_json">Перезагрузить codecs.json</string>
|
||||
<string name="dev_reload_codecs_json_hint">Подгружает overrides из assets/codecs.json без перезапуска. Откройте настройки снова, чтобы обновить списки.</string>
|
||||
<string name="codec_priority_score_suffix"> · %1$d</string>
|
||||
<string name="label_audio_codec">Кодек для звука</string>
|
||||
<string name="label_audio_codec">Аудио кодек</string>
|
||||
<string name="codec_libvpx_vp8">libvpx (VP8)</string>
|
||||
<string name="codec_libvpx_vp9">libvpx (VP9)</string>
|
||||
<string name="codec_aac">AAC</string>
|
||||
<string name="codec_speex">Speex</string>
|
||||
<string name="codec_opus">Opus</string>
|
||||
<string name="codec_audio_passthrough_debug">Passthrough (отладка)</string>
|
||||
<string name="codec_audio_auto">AUTO (AAC)</string>
|
||||
<string name="pip_enter">Картинка в картинке (PiP)</string>
|
||||
<string name="pip_unavailable">Режим «картинка в картинке» (PiP) недоступен на этом устройстве</string>
|
||||
|
||||
@@ -318,12 +318,13 @@
|
||||
<string name="ota_download_failed">Download failed: %1$s</string>
|
||||
<string name="ota_notification_channel_name">App updates</string>
|
||||
<string name="codec_priority_score_suffix"> · %1$d</string>
|
||||
<string name="label_audio_codec">Voice codec</string>
|
||||
<string name="label_audio_codec">Audio codec</string>
|
||||
<string name="codec_libvpx_vp8">libvpx (VP8)</string>
|
||||
<string name="codec_libvpx_vp9">libvpx (VP9)</string>
|
||||
<string name="codec_aac">AAC</string>
|
||||
<string name="codec_speex">Speex</string>
|
||||
<string name="codec_opus">Opus</string>
|
||||
<string name="codec_audio_passthrough_debug">Passthrough (debug)</string>
|
||||
<string name="codec_audio_auto">AUTO (AAC)</string>
|
||||
<string name="pip_enter">Picture-in-picture</string>
|
||||
<string name="pip_unavailable">Picture-in-picture is not available on this device</string>
|
||||
|
||||
@@ -79,4 +79,4 @@ _Assumptions: current MariaDB crash/ticket/session structures are available; no
|
||||
## Source linkage
|
||||
|
||||
- This markdown file is the editable source for:
|
||||
- `tmp/GRAFANA_vs_others_graphvis_pivot.pdf`
|
||||
- `docs/GRAFANA_vs_others_graphvis_pivot.pdf`
|
||||
|
||||
@@ -42,7 +42,7 @@ endobj
|
||||
endobj
|
||||
7 0 obj
|
||||
<<
|
||||
/Author (Android Cast project) /CreationDate (D:20260602143110+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260602143110+02'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Author (Android Cast project) /CreationDate (D:20260602144833+02'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260602144833+02'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||
/Subject (\(unspecified\)) /Title (Grafana vs Others GraphVis Pivot) /Trapped /False
|
||||
>>
|
||||
endobj
|
||||
@@ -53,17 +53,17 @@ endobj
|
||||
endobj
|
||||
9 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2837
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2776
|
||||
>>
|
||||
stream
|
||||
Gb!Sm=``=W&q9SYkXT2.;7oK>:J0snZUsN9b1&P`.Ku#tKU>gH;RY`>DrVrcGt_/)h0mq?'?_X>5bHrcJ/kf`a-"-#*8pJkVJdJo>Z`PnnLYgu+5qTeo&I[9P!NX3Xr"Y\Ks;A;JWKDAfWo#=$TtZ+=_L;[=Ae#%HjAre$*NN[D/d<2[rB0?`>5U,2@(<RLa"4`nO3e[H'-Z(9Ap?=%G\I2"Rg)'Xl0f1>EZ8+8AiRO0%m+<?GPP^/:N5ni=`*Z(]8PNCZ"%$3j>/XkeQ6)a'&^?CBnIo01+pNF])94'muO$\qHDHXB=um[j8MZ7660tKTn:rd^I;#U,9#T75Iuk1"9c(-b"cX'M(NqD8R!(FgaE-]GF_b+3-_DI=X)V;!g5-8+$MONCb;U\JU@R\)Q&._#tip$Hq=G;=W,WfMBXlE^:cgJ*L2,nA=-K=k=a%GJGPnhl[HaX-7l`e0/!8M-uD$17G%d+k[NLgq^4J^bN-EOK>:*ZF1J6[WA%p/ljKmObO?^VBoO&%4=(5F.\a/+]O&3b+bR?c+p>_QrFg0QM/4K<Rr\Ek1AcMcRR.=\/D9iIL4Q9ROkKZs8#,u7a>>t]]`;12_+<o-EJ'46Haj2+fV_Cc5.S#Z?ial[aVBqn0tOrqS8P:hnH6skFugnG%aZ'HU\[g7<R`L04,$]=^jt>L-sD[VTe#ir?[ab?f.0F:3kaBC,'hS?!?)$^rs/]L*EY[1jLs,G;Xk3s#g=iiSVdi9l'`F,nP"4ppWl2S<`Q@U+#rsnc/=s.s<-Z=*jdioa[Z)mS0,4]:ojt)-k@YK7Uqlp7C368C'U(-ME5]h,&JQ'(6%AK7Ru#i#`C&]7E=:bk@&Q;uRKKk+P`B9-9NFZ<2YsKoa("#R$9&"F_??oSsbt6YHCYc:3ftk79C`)n)HUgH6kVFr9'6G^mL,K^k25`(#W/,XL0o]1s^jh)J8BEgLdu0QNX[.#VmLC^I1oKG<pNV=h9t?:\3nP6M2.7V+/`5XD3cf3^^6nkr&,o)e>_ZNdLAQ8ERRHN'eJAX"EWY[W;3QGW=f`KX:RC[&9>.AFgSELIT,)LbfR>lj\L$)GO_&KN93]4AueMD\.S=6\bCCCgcGH</nO)J[:\jR,YtEJsZ3eTc^GX>Ks8eg54?X9jtFAR[%d=L0N_F,nb0P['>_r<f?N%'B0pBY>-]l*jq$7]J\`D-</3(RG;gI3q+e@J$.dOZ=/:'/gS]KkPd8,kMf,8a8e0Vi3I$$Y(]mU3mA\-'T\n"pm.G!R?cYd!",`iI"C@)7(P+#Tlu+ZpV#UkC%8\"F_ud>mi9\[PE)p@_=7<'f$4+[O6G!%5]X9cY\Bu=llBUH"#i<ETGkl-[T!_4`o^FfQtljJ0!0]R)0RR6dH/oZn9VAG"'`eQIWf*:eC9))Ac-$>tWt49O[\'"i`_horVorP;SX,B(6_<11P]kjgU@h5%[%$W#f.Mg)#9?Ob.14?IA(G.kmD!`&^.$\m1PDK-cnm=?Y;HLapQP#SDQ:(-%\S)GK*9U\rWW#*q0FonFb,RM)Zbf"HL$n[R.^Sg&,rhWbl=pl$4o.9[cdWZNfR1%"G1FO0MiLPkrd1#R%7n$BlT"DnIY#-J,m+=-1W@dfYK/!^0,&er.U']CBO"lji&JVFr9BFo,=I#FR8#_q44;^k>9(-QAo:/e`Tqm*qgV.:VZD]SjVPnENY:D4<e\,`55Tfr@aEOBT9aF`89h%gGeBM<+dE4ndrPg?gWmj5n^5jB@/$tch@[Pb*n.5G'n\d2#-hIBU<<!8\ZcmqXg&:TKC5Rg!bdca52^o?$_8hm5A`Y2Cp"rU@*U\?Hf]/;CMV=fAjFFZ'Z3JN*Yh'k_Y6m.B;J]iMFna,Z@So$RLkI]%>70=Dt3g=++4ID[Ea&QF^_recDH\&Bu.#,u64OAM&4q\qM?lou-p8f553?IlF\D#akaC>@I-A)3@LpnmnBCdOLH^NK/]q#5>4u!G;4^BG?.kcjo*SKEn6O,qo$8';A%7?aP&:n9th"ZB;\6jMg72nt/jFoRL,0++6M16]2,3A;(oS4.4CRiYaFu,8B2_/]b&`_;?;5nkqBXr\3=Zj34a*?g1g]<!Ka-(C"GcDLo`t>=ti6CcG[30(E,394MWo`DHVT@OtVBIUq.ES%OJ<eplM'0fP7F27WC1P4#c+V)K`B?l5fgh-i1t,$o;*%1B`Vs00.Z[+s0l^'2M.eua=Nm\CTtbk-L<ke$:8[.*e'#'i;V$G5V)QI".<L*+E8:_T1-`o-3_?T#j:gdA<E@s#Y.qn/AU#\8=mu<GnOg7p-93-eYfi0cSZ4f>$L`qIWR3)4]A4"co_Ro]@mDNTj.\cT5hs`k5+M-lj1H*Ee<C^5Z)VrPM"s\c!3N<un<W8SV4&g^@1pK?@7N2XUEUh[VL'0>'%cZT..mL^/SkQ[BP%BrN_Om/c=OJd',PF+)$Cn47O1)2]dK(Ni%^HOPA3S-h+mtOS?fq*m]Q!g&DN.g(3cJp$9V['Q)7ZY=A4,Hl36I[XCo4:!hr,%%k`/:cZRJZM\7bf`:(H]lnaF2!AjC*IhE&G62KC1nP<elSe4ZUJJlI"4_$BiJq5@H*MeE,h3d2FMTq/=7F1HMiN]9D)=]GY*#r7a27@oH7p=EDC!dq_#&I1QKG2Bieg<8L]:X\A1:"fPWG.AUELojW?PN/R6uqO)GsgCNm%M/-FAo-A+*Ikt02g)Z#E8(ZL_LDCpVHRE002tr-bei8,CAT/eGL'.#B:8NXNWg_:Y%?MqC5G9$r]Umk'E^5GL9ZDl`%T5,oa]Vnb*Z`9<pIp2BsU,b??R-]3o7IdcQA/boj6"h[NgZK5gC"gpatO~>endstream
|
||||
Gat=-gNV@2'7\>80opEDN%JCNSnO:1p\WYi:$A0sa>lE#(8-Yl?-=R's8!bXp]>6#b970(,L;>2qAWjnN\<cuoiD-cVD.j_J3dR(#K+t@R!sb$l,Bh6A%U3524^*E!S?iugU&P[%`t1-&^rX(1WPODHD%f,)-[<@M[ST[<+:sJ$:PL[4=Pjn.DRYpgN<rAl-fV:P:/t3*l&4")8$O?_O$K69DF^/S;-q`Rdi@h8%t"i%l!r$VSBst/dp2(420FU[]H@W[Vh$E"8:XH[C-IF%2]n)O\-.UrA\4-iToRZ,@!RBi6aX1W;k#7?jKU<!R3"qGnP/4coBtj$[K[FON&HURSgm&TFhVl)>AhcSu"r&e($"mo;(s%%?@Qag7A``0rkbs5)J?AGRhp%1s[ttE.u%UKDBH,<5,ef`cg)P:%>Pts*ad+lQ"u$]4^Uj]n:_&e%RC`]ZJ`_j?Kp:^j'+O:04(SLB/iV[pWUB0_ijp(j,W"+L\0%[W)f?5A?Lk4jM<ml[We5MPhO>d+AqT9='%l/LNQ21!ti!V;i*NAFEn@31950*+eJ"408#k]P3N7D6CXgCN6r$"TNQKP;'iFKpP)fm$EebPH/u7_Z.?tPgeA_0->KMBT[?G.'f*.k_M$d_>ZV&5K1ce2uPd8jU+Z7AlR>1>cj0N^%6'hF"1Xj/G:75P5L+LS\b/gq-;!M]4.i9UMLokXK\oEi9);B=7H"^,?AE]SAd;o7%^q`QAmWg)T6P+LcA3/,;SM*&OGcAd?gcQkK3\`k?@eBZR&8`c\:_HCG(&_Z70Oh[X.R1r;HLZo8%1Ukl(7R0A$]lX_J9F\rOpE#g\C(2[T?+a*M6*@nR%OCb-#&Y"AaRNs5Vd,lq4>K7L"cJ8LuG^f<tA<E&a=9/)<E@=G1\[4%=;hUQuo_piLS$LK_.C4ko6Wsr?0od]`HeaR3ZBQ3^kr&j"T"sREVB\K"sPs!6*b%lf!hQF^u"]/'H:7's4SRCqr\8IQO>elfW=i__G_@>upn-j[__69B*S$KCe^f0B.\D_!1Vt(.MW7(J:qc<rEMQhU+!,ju(!nJskBCPP-E`8Q@Yem-WUBQ7@!1of)7Y1r3XL",r=Afq=##\ZjL4@FV;?>ld!mQi\ej>,]Xq9-B@>SHf/6'O\,R92\+H52[$7Qdp9)9Ng\3YNneO5/=XjHtfQGt=bSl24I^ni<]YM#,(]L&QP&2'[0nft(Bb-fCCKB5RoCAV`0JO+j"B/pJ)C#V*=E#:&4XuUhBGfUV`L6H8-Yk$i%j2blbBMP".Kmpua/'[0a'=qR\#]V&RV'.l+26Ru>@UI0^ZWp&]$bB5"3s'A)(t+H4R%5sM=a%43[$W3Z4=G'3o.\*N*-G?C'&XDHk$$UbJEsY<f3Q1*2o2LgN^KOqDRDX8$ZSc,Q@i#`E/qb;]83_rBVu`gVWPFT:<qktR)[tLOr-.eNmU,TE0_UO7du)Afe($^FNlXbpFHWs._.RZRD07oNX+'gfdk]/gDY7'JhMbNOB#H<M\U"t)?"&teL'moS=BI'edG1G+XD'K`KF5g@'E[Q1mSLfZL+6oKR)2NW]^ZJ=Eoe>Fg"S,dr&+R\L@>"fc,,"3Le@T1qToKEgU@M6;T/tedBSA<d\9eDXnb0iC0[(9O(X&)iL_(l'hJXZFJVF"+/`\j4$A<Nisf2p6sFg>NrQZ89N3+Q=(Yi_64j=+m>I5gg8I<lYoc]^ro?Y)HRCRS)"HXg0Ju25QL0:W]QOf]b2od%X,$m(WRCCN#19QaOKtS!MhO<:b*T,O`;Hu(=@Hf)9dA^<"+>qiTReLVouPOQKD/,P#3n?fG`W+oG'($p+g6L8_6CK_2l@ROK9[(_@T*=)=,!KW+,*5pYrt(Yr>5F.-%@3DSQkk73WgpTPmfqF`=t2heO((^^kau69`FhYhg7qUHC)Q!cqM_'\nhU*5uj6\i-BYAf%Lg/`(2g!sNnO&N?s%9Hi2ja<e&O*)5VKl^fi4!Wa*$:Xfr8eOa/]2!S)?<(Jr!4WGj4B^lO1Bn[/Bd8L'aPe7%:DR=):OqS$fG@u5cbMrfFC1G;7fO,tU=d*CJPFt<N9p/hDQ&)c86R(1r4Eha(;I$W.`=[_:*gcM6;:A7o.XWE(eY8j`Nf4*b9%;RKSdEe;qJ"m@eDR=?6<;Q"NW9?)?3VT\TR4$Eel'BLZ-lE4I#"oL:@uGC=uR@HY_aEF@GH6$$N)Pe;fC]C!h.\ec.n0pV279NkRmJNJpA-p)qf7l\:qe&;)<<'SL*@bS]Z+TFK9g-a2;5e\,<Vi;Z..iFfdFS)2oZujVhSpS949cCt4?G7#<pa0VdB<`V*2U48D`G6H$NCi7O'\.EDn<>'tudE=J`_UO[7IAgMI$EBrO/Tu'B:QE!RgJc2:S#rr7K=K?kA-!mGL!ia*"5D/n[%$gYS0<O.FQAgROo!%N+rnG_!BK#B,\f<"1/W9QKTk'$UR*d=,j+4jQP))gMhZu\h0Z7QR1)bnTp!o<A,[B3[5@VMN4\R5uiWA6'_[u76O5&DmA53q7h@2>m67Pj5neGFTrp7O'`\@e2HTKY(6^5,Bemi?K\A=kE>Uc9s_K^fki"CZ"jE/DZ^2*\iCV<L/(dV%NCeOiS=t3]F7J;\9pP+d+kVJTCR].ue^AWPP0o29)"\rob!VXYkpu>D6\.H3p^FW,Vs$aLn29BKkNaZXb[XUef!iR9S=G5m.JspFa#H2>jP3m5o\JY"QpC>0E$0D^VZXi2ZgSg4pC/G?``$daH+AKBpl(*pTZS^8gh<e:fE:GT(>fAu~>endstream
|
||||
endobj
|
||||
10 0 obj
|
||||
<<
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1169
|
||||
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1790
|
||||
>>
|
||||
stream
|
||||
Gat=)gN)=4%"7kOi%_jCBo/!$3IT%]]=k#H\bIm4N'ob_PphonS@W'7OKrCW'1^N3`qIJ5!"M9P0SBFud!t3n^src-^P!.r=KNa*8IVY(pp-@e2%00",UEJN(kn2c,`.[$rQlO/Zein[69#+m?r^W#MP49?8mSs5KYW5]l`(Sti)\D9>#bh;]=QOSf?.4?V6]4sXM2U;ZcdiZc[-W52W?D_NVR&GP)$aQ?W5l2oK;+!.83[MWBo&M0Uo5'2A_bHG#>V@.<Ego^`Pm":b)PNfcd"pK@R)P\UZoTQMfkPcCpJ,!KZuaYa+4u&C9)CmrJ>,25S)l/T@YAeM9')>k/mNXm/u43qJAiMHbWd9DOVs^kdjulbH`H]gJpEh,!6b4_gc[fNO32:N,i1[B_>+Dp_h+h62$!E:`X*F'V&K2o<@6C1C[XCY6"!@hO@/37bGj_AIJ<k?uGU"3)Wb)pG4faK7epgLLh(0"/E/OZ@g>I*EB2^;_p_;Xm$B4EHF&q6P1NR@f$HF^ohc+P0F\(ERrW+0I6"U#M&-/CD-F>`n8J?qWsk(fN@#mfOCI!<6nB)1akT2mpI[.If?W97N\loGX58EKVJ<8@W25!_L=""A"X%.#EIB9,=<@O>NZS3-:CLo:l=]E(mPg>f%If[".g^%-jE*'>05H5%"e;J+m:&FC=flil9Ge-Yi536Xf5eL[LF?F?U*`:>H5I4^-6l)Eu$V!=?Ug9a9g@E8.AfF",u?LshMU;0E6EAT^""NX*PFa9V(;c9Ih,QM&2I&nRnHP="DLR(2#3&e%t'\$S"Z0AJlHGYq&of2@umC3hXVMYk.MJS)\Y(nKTtLKtZ!]Ek\-Zf$"&r;s(%#'iU_8/Foq[cqqPmg.h169@&<;/c(@qgH2/,kKCQ5=Ig)E7"ge4mZ>[Vc-*SBJ+[^JZHYGhRr/oVj+C5^-GdO;CBL2Q;UL6mhsB6&,T*`=?Jm>*r=a`dQ#NY7dU[H9g=D<@keA(.XhY]GEM_V`GlubcD(n1G,Sr'@Yag2pusAc9Ao6m2:T-\Gk0DKEh$N\P*j_Ihsp;d7P^FP8I06a+26#k!j@6&<X5aHY9c;WduiVampH8<5u%Q'l8pm-#?,eVaUSMuA7,X0Ht8$DL>tUToMN9i^,$9Q#C95cFq+-<rh[*]qZga5!nUY2(B~>endstream
|
||||
Gatm<>Bed\&:Vs/R)cS5NFhpS53:40GIMpfVEF!0Ym""j$S0Drm'#</N/aN]_7PI)*&7"CUMiWM2\9rt7EfD-"5HX[nf%Vh_K6"#ZTX\)L(Dak^G!ZSVB=NP9*6!I+L6F\Hh\SL*FkE,$Uc*XBEZd(!F:B,J?Gs3/j1.;b\!\N^krhhZ;SIg-$#kB4C.Me/dfn#.-=<DhSAbF9],+H^>`oA7c<#H%O*!Hd-lZs4-<0a$9!kG-]GhNI5gkU%]n%IK]!$**CV4X$f@,bln)]3f6U%#m@.Q(3"L2If+3/s+>.e_;fJrs`=VK)Cj:/R@6K.f4[ug5'&<O!_t-j5jMsOjM&Tn\EARmqpHg:E1.Z[;Q<^R&3l,@ZJJ6Bb:-LD#ncI"m+?K1o9jMtm&uMMB"giE]j._U0pVYss@''lb-<m'+8iC/_K2bC5.(I#4:=0>#SeY^\QB8CP24U6M%X]cFG0jf6KS.o$rih.XI@Lap=')EGiSi5fJlaVO-'m7n(L7qKGk/!3N&6K45crt'1lQAbSX>/,aFru6rj7!;,)*uDX11Sl3n+Nj=sr]4),:T0Gj_j=M`>?[AqYq[TgW&A+`h5gi^rq`iJ'<-3P*c@_"[R24*!^7-a<LHq!l5piO-??o?Ak@UC=jSK@BkVg%sCTbj3O+%tf(Ac[_eAJG3^&J>:[\\"IF#R^f.b]JatdcBIS6:"H\^)g#[a_hN#FmKRIuf:CE5H7/4!^<l?ie/q6AcM&uG+)g>d#cc(;WiC,=*n<.;T)rr)@LdlB.G4SN<65No)8$=L!f=keBqe#dK<%S_WsYI"`L\kl]`VDL*4)dbYU'sNrq/Fk-!?f0$?GjRP\[L@9BBV8R<EBg/B5qlOd^fFk&0!>_4f\<o-BuI_]qt"4Qf.J_L]9h\V*sc92pR5V'H_.3kSt6(jqD:@d(?lIpSe=2k1gilf\o638sFbKH]-;:m;=6Rk3#LiTTV!,cqlN8/e5\:X0bQ(*"f_%O[Js:pO+Tf3QD!5jKQfp@U3m"b3WmEXh!Ia7&gJj1&,h]T@1\78fPq)-c'oMt.U*YBb)]`<]pbiZFaYKpaHUT"Gc]4AZgHl5`'=*%ci8Th<G!hX(*NX[RueHI"/J$T?&ZQjJVL)`9V!CF7F87kf[5l>#YmWiVe%q4Jt6c(pS-XV>I)FnWm.^1:BKja4%FfF4+o/JSqVq*_>`@H@j0[d$#['G!a@+:tl=(dc9([l8M5]aNk&i8Y\fVpqbCYnUY+5$5!p*-N"@X4P.A#?`\%$>D!b:FqhjUJ:D^==q-/E_j#._gVRfbmH0?I(ZM9r=RjX(_#F#HQ/d^>e7Ceg;5/V)l:A'-pF*3]gDagierGWnJp:,Icugn^2KG^IHhlffJ2?L7\^%b0YJ@[Q:0$o3b"JP&ZGGOL>@6$&u6^+U.DG+T6J>P3JcsXbDAE(-7mD1@87km6o)0oN`iCD%]NUDb0NM!LC"rOHBY5q_O4BLS>eB,f77.BG9%i.a;shSW'u&.RkomQnGq+;DiNql4FtWeiAn?-arSSdeet"+SaUYX_d&3e]ou].;VQ'G[Ac:[=-TjOX/WhqRPYY9Um"X4`>S5MSjMJ)oih>k7Cj`C!uPWi"KJWGL*VU'TXiG"$6"&,[3\pP'brr.+DaFWY!r@h6b:$85G@Af#5uuYJZUhVpi6Tc?6$I@o:BPVTs"AjQ;6lU[1;FY@=!/r?auN6^G5'WToSA?E7>e&X7Qf[C(:/CYNg%p9Y/bbdS5os[[Kn<2OW'!Vq,sBm9gSI%7rq%Z+#A:hR&2[nLpgIF2e~>endstream
|
||||
endobj
|
||||
xref
|
||||
0 11
|
||||
@@ -77,11 +77,11 @@ xref
|
||||
0000000796 00000 n
|
||||
0000001102 00000 n
|
||||
0000001167 00000 n
|
||||
0000004095 00000 n
|
||||
0000004034 00000 n
|
||||
trailer
|
||||
<<
|
||||
/ID
|
||||
[<8697b2018a3d689f70795c513a06ba7d><8697b2018a3d689f70795c513a06ba7d>]
|
||||
[<a4799bc865b1d3f8e1198486b0b00e10><a4799bc865b1d3f8e1198486b0b00e10>]
|
||||
% ReportLab generated PDF document -- digest (opensource)
|
||||
|
||||
/Info 7 0 R
|
||||
@@ -89,5 +89,5 @@ trailer
|
||||
/Size 11
|
||||
>>
|
||||
startxref
|
||||
5356
|
||||
5916
|
||||
%%EOF
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build tmp/GRAFANA_vs_others_graphvis_pivot.pdf decision memo."""
|
||||
"""Build docs/GRAFANA_vs_others_graphvis_pivot.pdf decision memo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,16 +13,26 @@ from reportlab.lib.units import cm
|
||||
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
OUT_PDF = ROOT / "tmp" / "GRAFANA_vs_others_graphvis_pivot.pdf"
|
||||
SOURCE_MD = ROOT / "tmp" / "GRAFANA_vs_others_graphvis_pivot.md"
|
||||
OUT_PDF = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.pdf"
|
||||
SOURCE_MD = ROOT / "docs" / "GRAFANA_vs_others_graphvis_pivot.md"
|
||||
|
||||
|
||||
def p(text: str, style) -> Paragraph:
|
||||
return Paragraph(text.replace("\n", "<br/>"), style)
|
||||
|
||||
|
||||
def _cell(text: str, style: ParagraphStyle) -> Paragraph:
|
||||
safe = (text or "").replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
return Paragraph(safe.replace("\n", "<br/>"), style)
|
||||
|
||||
|
||||
def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> None:
|
||||
data = [headers] + rows
|
||||
styles = getSampleStyleSheet()
|
||||
head = ParagraphStyle("TblHead", parent=styles["Normal"], fontName="Helvetica-Bold", fontSize=9, leading=11)
|
||||
body = ParagraphStyle("TblBody", parent=styles["Normal"], fontSize=9, leading=11)
|
||||
data = [[_cell(h, head) for h in headers]]
|
||||
for row in rows:
|
||||
data.append([_cell(c, body) for c in row])
|
||||
t = Table(data, colWidths=col_widths)
|
||||
t.setStyle(
|
||||
TableStyle(
|
||||
@@ -33,6 +43,7 @@ def add_table(story, headers: list[str], rows: list[list[str]], col_widths) -> N
|
||||
("FONTSIZE", (0, 0), (-1, -1), 9),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
|
||||
("WORDWRAP", (0, 0), (-1, -1), "CJK"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F5F5F5")]),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
||||
@@ -71,7 +82,7 @@ def build_pdf() -> None:
|
||||
["Context", "apps.f0xx.org + BE Alpine (nginx + php-fpm + MariaDB)"],
|
||||
["Constraint", "No cron ETL/data copy/pipeline complexity"],
|
||||
["Output", "Decision memo: Grafana vs custom PHP dashboards"],
|
||||
["Markdown source", str(SOURCE_MD.relative_to(ROOT))],
|
||||
["Markdown source", "docs/GRAFANA_vs_others_graphvis_pivot.md"],
|
||||
],
|
||||
[4.2 * cm, 12.2 * cm],
|
||||
)
|
||||
|
||||
@@ -11,8 +11,15 @@
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="/app/androidcast_project/crashes/assets/css/app.css">
|
||||
<link rel="stylesheet" href="hub.css?v=20260601earth3d">
|
||||
<link rel="stylesheet" href="hub-logo-layers.css?v=20260601earth3d">
|
||||
<link rel="stylesheet" href="hub.css?v=20260532globe">
|
||||
<link rel="stylesheet" href="hub-logo-layers.css?v=20260532globe">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="assets/analytics.config.js"></script>
|
||||
<script src="/app/androidcast_project/crashes/assets/js/analytics.js" defer></script>
|
||||
</head>
|
||||
@@ -94,6 +101,12 @@
|
||||
</span>
|
||||
<span class="hub-card-label">AndroidCast crashes</span>
|
||||
</a>
|
||||
<a class="hub-card card card--lift" href="graphs/">
|
||||
<span class="hub-card-icon" aria-hidden="true">
|
||||
<span class="nav-icon nav-icon--graphs"></span>
|
||||
</span>
|
||||
<span class="hub-card-label">AndroidCast graphs</span>
|
||||
</a>
|
||||
<a class="hub-card card card--lift" href="build/">
|
||||
<span class="hub-card-icon" aria-hidden="true">
|
||||
<span class="nav-icon nav-icon--builder"></span>
|
||||
@@ -158,7 +171,7 @@
|
||||
var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover'));
|
||||
var logoEnabled = !!(stage && supportsSvg && supportsLayout);
|
||||
|
||||
var logoBuild = '20260601earth3d';
|
||||
var logoBuild = '20260532globe';
|
||||
|
||||
function hubAsset(rel) {
|
||||
var link = document.querySelector('link[href*="hub.css"]');
|
||||
@@ -225,11 +238,11 @@
|
||||
}
|
||||
|
||||
var useGlobe3d = logoEnabled && (params.get('globe3d') || '1') !== '0';
|
||||
var globe3dSkip = { globe: true, continents: true, gridOverlay: true };
|
||||
var globe3dLayers = { globe: true, continents: true, gridOverlay: true };
|
||||
|
||||
function appendLogoLayers() {
|
||||
function appendStaticLayers() {
|
||||
list.forEach(function (name) {
|
||||
if (useGlobe3d && globe3dSkip[name]) return;
|
||||
if (useGlobe3d && globe3dLayers[name]) return;
|
||||
var img = document.createElement('img');
|
||||
img.className = 'hub-logo-layer hub-logo-layer--' + name;
|
||||
img.src = fragmentSrc(name);
|
||||
@@ -247,7 +260,7 @@
|
||||
}
|
||||
|
||||
if (logoEnabled) {
|
||||
appendLogoLayers();
|
||||
appendStaticLayers();
|
||||
|
||||
var logoOpacity = params.get('logoOpacity');
|
||||
if (logoOpacity) {
|
||||
@@ -259,12 +272,35 @@
|
||||
|
||||
if (useGlobe3d) {
|
||||
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
var globeYawDeg = parseFloat(params.get('globeYawDeg'));
|
||||
var globeOpts = {
|
||||
build: logoBuild,
|
||||
reducedMotion: reducedMotion
|
||||
};
|
||||
if (isFinite(globeYawDeg)) {
|
||||
globeOpts.initialRotationY = globeYawDeg * Math.PI / 180;
|
||||
}
|
||||
var globeModuleUrl = new URL('assets/hub-globe.js', window.location.href);
|
||||
globeModuleUrl.searchParams.set('v', logoBuild);
|
||||
import(globeModuleUrl.href).then(function (mod) {
|
||||
return mod.mountHubGlobe(stage, { build: logoBuild, reducedMotion: reducedMotion });
|
||||
return mod.mountHubGlobe(stage, globeOpts);
|
||||
}).then(function (handle) {
|
||||
if (handle) {
|
||||
stage.dataset.globe3d = 'active';
|
||||
}
|
||||
if (!handle) {
|
||||
['globe', 'continents', 'gridOverlay'].forEach(function (name) {
|
||||
if (list.indexOf(name) === -1) return;
|
||||
var img = document.createElement('img');
|
||||
img.className = 'hub-logo-layer hub-logo-layer--' + name;
|
||||
img.src = fragmentSrc(name);
|
||||
img.alt = '';
|
||||
img.decoding = 'async';
|
||||
stage.appendChild(img);
|
||||
});
|
||||
}
|
||||
}).catch(function (err) {
|
||||
console.warn('HubGlobe: falling back to static layers', err);
|
||||
console.warn('HubGlobe 3D unavailable, using flat layers', err);
|
||||
['globe', 'continents', 'gridOverlay'].forEach(function (name) {
|
||||
if (list.indexOf(name) === -1) return;
|
||||
var img = document.createElement('img');
|
||||
|
||||
@@ -211,6 +211,31 @@ MariaDB (**required on existing servers**): `mysql -u root -p androidcast_crashe
|
||||
|
||||
**Attachments (005):** `mysql -u root -p androidcast_crashes < sql/migrations/005_ticket_attachments.sql` — files + external links (Google URLs auto-labeled).
|
||||
|
||||
## Graphs dashboard (`/graphs`)
|
||||
|
||||
Stage-1 custom BE graphs are available at:
|
||||
|
||||
- `https://apps.f0xx.org/app/androidcast_project/graphs/`
|
||||
- Uses the same auth/session, users, RBAC, and DB as crashes/tickets.
|
||||
- Hub card: `AndroidCast graphs` (between crashes and builder).
|
||||
|
||||
APIs:
|
||||
|
||||
- `GET /app/androidcast_project/graphs/api/graphs.php?days=14` (dashboard data in user/slug_admin/platform scopes)
|
||||
- `POST /app/androidcast_project/graphs/api/graph_upload.php` (ingest `graph_session` payloads)
|
||||
|
||||
Fake data helper scripts:
|
||||
|
||||
```bash
|
||||
cd examples/crash_reporter/backend
|
||||
php scripts/generate_fake_graph_sessions.php --count=240 --days=30
|
||||
php scripts/upload_fake_graph_sessions.php --url=https://apps.f0xx.org/app/androidcast_project/graphs
|
||||
```
|
||||
|
||||
Payload shape (`schema_version=1`, `ingest_kind=graph_session`) includes:
|
||||
`session_id`, `company_id`, `user_id`, `started_at_epoch_ms`, `ended_at_epoch_ms`, `duration_s`,
|
||||
`direction`, `transport`, `avg_kbps`, `packet_loss_pct`, `ntp_source`, `install_source`, `meta`.
|
||||
|
||||
## Default accounts
|
||||
|
||||
| User | Password | Role |
|
||||
|
||||
@@ -18,6 +18,10 @@ server {
|
||||
return 301 /app/androidcast_project/crashes/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs {
|
||||
return 301 /app/androidcast_project/graphs/;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/crashes/assets/ {
|
||||
alias /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/assets/;
|
||||
}
|
||||
@@ -40,6 +44,32 @@ server {
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/graphs/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/build {
|
||||
return 301 /app/androidcast_project/build/;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
return 301 /app/androidcast_project/crashes/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs {
|
||||
return 301 /app/androidcast_project/graphs/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project {
|
||||
return 301 /app/androidcast_project/;
|
||||
}
|
||||
@@ -68,3 +72,29 @@
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/graphs/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ server {
|
||||
return 301 /app/androidcast_project/crashes/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs {
|
||||
return 301 /app/androidcast_project/graphs/;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project {
|
||||
return 301 /app/androidcast_project/;
|
||||
}
|
||||
@@ -53,6 +57,32 @@ server {
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graphs.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graphs.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graphs.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
}
|
||||
|
||||
location = /app/androidcast_project/graphs/api/graph_upload.php {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/api/graph_upload.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/api/graph_upload.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ^~ /app/androidcast_project/graphs/ {
|
||||
include fastcgi_params;
|
||||
fastcgi_pass unix:/run/php-fpm.socket;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/localhost/htdocs/apps/app/androidcast_project/android_cast/examples/crash_reporter/backend/public/index.php;
|
||||
fastcgi_param SCRIPT_NAME /app/androidcast_project/graphs/index.php;
|
||||
fastcgi_param REQUEST_URI $request_uri;
|
||||
client_max_body_size 4m;
|
||||
}
|
||||
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
|
||||
32
examples/crash_reporter/backend/public/api/graph_upload.php
Normal file
32
examples/crash_reporter/backend/public/api/graph_upload.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
if ($raw === false || $raw === '') {
|
||||
json_out(['ok' => false, 'error' => 'empty body'], 400);
|
||||
}
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
json_out(['ok' => false, 'error' => 'invalid json'], 400);
|
||||
}
|
||||
if ((int) ($data['schema_version'] ?? 0) !== 1) {
|
||||
json_out(['ok' => false, 'error' => 'unsupported schema_version'], 400);
|
||||
}
|
||||
if (($data['ingest_kind'] ?? 'graph_session') !== 'graph_session') {
|
||||
json_out(['ok' => false, 'error' => 'ingest_kind must be graph_session'], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
GraphRepository::insertSession($data);
|
||||
json_out(['ok' => true, 'session_id' => $data['session_id'] ?? null]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_out(['ok' => false, 'error' => $e->getMessage()], 400);
|
||||
} catch (Throwable $e) {
|
||||
error_log('graph upload: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'store_failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
22
examples/crash_reporter/backend/public/api/graphs.php
Normal file
22
examples/crash_reporter/backend/public/api/graphs.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/../../src/bootstrap.php';
|
||||
|
||||
Auth::check();
|
||||
|
||||
$days = (int) ($_GET['days'] ?? 14);
|
||||
if ($days <= 0) {
|
||||
$days = 14;
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = GraphRepository::dashboardData($days);
|
||||
json_out(['ok' => true, 'data' => $payload]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('graphs api: ' . $e->getMessage());
|
||||
$out = ['ok' => false, 'error' => 'graphs_load_failed'];
|
||||
if (cfg('debug')) {
|
||||
$out['hint'] = $e->getMessage();
|
||||
}
|
||||
json_out($out, 500);
|
||||
}
|
||||
@@ -237,6 +237,29 @@ a:hover { text-decoration: underline; }
|
||||
transform-origin: left bottom;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.nav-icon--graphs {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-left: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
}
|
||||
.nav-icon--graphs::before,
|
||||
.nav-icon--graphs::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: currentColor;
|
||||
border-radius: 1px 1px 0 0;
|
||||
}
|
||||
.nav-icon--graphs::before {
|
||||
left: 4px;
|
||||
height: 8px;
|
||||
}
|
||||
.nav-icon--graphs::after {
|
||||
left: 10px;
|
||||
height: 12px;
|
||||
}
|
||||
.ticket-col-issue { min-width: 200px; max-width: 420px; }
|
||||
.ticket-issue-title { font-weight: 600; }
|
||||
.ticket-issue-brief { font-size: 0.88em; margin-top: 4px; }
|
||||
@@ -916,6 +939,14 @@ a:hover { text-decoration: underline; }
|
||||
.exc { color: var(--danger); font-weight: 600; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin: 20px 0; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
|
||||
.card canvas {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 180px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.cards--lift .card--lift {
|
||||
transition: transform .18s ease, box-shadow .18s ease, border-color .18s ease;
|
||||
}
|
||||
|
||||
109
examples/crash_reporter/backend/public/assets/js/graphs.js
Normal file
109
examples/crash_reporter/backend/public/assets/js/graphs.js
Normal file
@@ -0,0 +1,109 @@
|
||||
(function () {
|
||||
function basePath() {
|
||||
return document.body.getAttribute('data-base-path') || '';
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, color) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = 'rgba(90,110,140,0.15)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
const points = Array.isArray(series) ? series : [];
|
||||
if (!points.length) return;
|
||||
let maxY = 0;
|
||||
points.forEach((p) => {
|
||||
const y = Number(p.y || 0);
|
||||
if (y > maxY) maxY = y;
|
||||
});
|
||||
if (maxY <= 0) maxY = 1;
|
||||
const pad = 20;
|
||||
const plotW = w - pad * 2;
|
||||
const plotH = h - pad * 2;
|
||||
|
||||
ctx.strokeStyle = 'rgba(120,140,170,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = pad + (plotH * i) / 4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pad, y);
|
||||
ctx.lineTo(w - pad, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.strokeStyle = color || '#3d8bfd';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
points.forEach((p, idx) => {
|
||||
const x = pad + (plotW * idx) / Math.max(1, points.length - 1);
|
||||
const y = pad + plotH - (plotH * Number(p.y || 0)) / maxY;
|
||||
if (idx === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function renderBreakdown(el, rows) {
|
||||
if (!el) return;
|
||||
if (!Array.isArray(rows) || !rows.length) {
|
||||
el.textContent = 'No data';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = rows
|
||||
.map((r) => `<div><strong>${String(r.label || '')}</strong>: ${Number(r.value || 0)}</div>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function bootGraphs() {
|
||||
const app = document.getElementById('graphs-app');
|
||||
if (!app) return;
|
||||
const status = document.getElementById('graphs-status');
|
||||
const daysSel = document.getElementById('graphs-days');
|
||||
|
||||
function load() {
|
||||
const days = Number(daysSel && daysSel.value ? daysSel.value : 14);
|
||||
status.textContent = 'Loading...';
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', basePath() + '/api/graphs.php?days=' + encodeURIComponent(String(days)), true);
|
||||
xhr.onload = function () {
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
status.textContent = 'Invalid response';
|
||||
return;
|
||||
}
|
||||
if (!payload || !payload.ok) {
|
||||
status.textContent = (payload && payload.error) || 'Failed';
|
||||
return;
|
||||
}
|
||||
const data = payload.data || {};
|
||||
drawLineChart(document.getElementById('graph-user-sessions'), data.user && data.user.sessions_per_day, '#3d8bfd');
|
||||
drawLineChart(document.getElementById('graph-slug-sessions'), data.slug_admin && data.slug_admin.sessions_per_day, '#5eead4');
|
||||
drawLineChart(document.getElementById('graph-platform-sessions'), data.platform_admin && data.platform_admin.sessions_per_day, '#f59e0b');
|
||||
drawLineChart(document.getElementById('graph-bitrate'), data.platform_admin && data.platform_admin.avg_bitrate_kbps_per_day, '#34d399');
|
||||
renderBreakdown(document.getElementById('graph-ntp-breakdown'), data.platform_admin && data.platform_admin.ntp_sources);
|
||||
renderBreakdown(document.getElementById('graph-install-breakdown'), data.platform_admin && data.platform_admin.install_sources);
|
||||
status.textContent = 'Loaded';
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
status.textContent = 'Network error';
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
if (daysSel) {
|
||||
daysSel.addEventListener('change', load);
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bootGraphs);
|
||||
} else {
|
||||
bootGraphs();
|
||||
}
|
||||
})();
|
||||
@@ -85,6 +85,16 @@ if ($route === '/api/ticket_attachments.php' || str_ends_with($route, '/api/tick
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/graphs.php' || str_ends_with($route, '/api/graphs.php') || str_ends_with($route, '/graphs/api/graphs.php')) {
|
||||
require __DIR__ . '/api/graphs.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/graph_upload.php' || str_ends_with($route, '/api/graph_upload.php') || str_ends_with($route, '/graphs/api/graph_upload.php')) {
|
||||
require __DIR__ . '/api/graph_upload.php';
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/api/ticket_attachment.php' || str_ends_with($route, '/api/ticket_attachment.php')) {
|
||||
require __DIR__ . '/api/ticket_attachment.php';
|
||||
exit;
|
||||
@@ -118,6 +128,11 @@ if ($route === '/login') {
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($route === '/graphs' || $route === '/graphs/' || str_ends_with($route, '/app/androidcast_project/graphs') || str_ends_with($route, '/app/androidcast_project/graphs/')) {
|
||||
$_GET['view'] = 'graphs';
|
||||
$route = '/';
|
||||
}
|
||||
|
||||
Auth::check();
|
||||
|
||||
$grouped = isset($_GET['group']) && $_GET['group'] === '1';
|
||||
@@ -176,6 +191,7 @@ if ($view === 'ticket' && isset($_GET['id'])) {
|
||||
$pageTitle = match ($view) {
|
||||
'home' => 'Home',
|
||||
'tickets' => 'Tickets',
|
||||
'graphs' => 'Graphs',
|
||||
'reports', 'report' => 'Crash reports',
|
||||
default => 'Console',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$opts = getopt('', ['count::', 'days::', 'out::', 'company::', 'user::']);
|
||||
$count = max(10, (int) ($opts['count'] ?? 180));
|
||||
$days = max(1, (int) ($opts['days'] ?? 14));
|
||||
$out = $opts['out'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
|
||||
$companyId = (int) ($opts['company'] ?? 1);
|
||||
$userId = (int) ($opts['user'] ?? 1);
|
||||
|
||||
$nowMs = (int) round(microtime(true) * 1000);
|
||||
$startFloorMs = $nowMs - ($days * 86400 * 1000);
|
||||
$transports = ['wifi', 'cellular', 'ethernet'];
|
||||
$installs = ['direct', 'play_store', 'mdm', 'partner'];
|
||||
$ntp = ['device', 'backend', 'pool.ntp.org'];
|
||||
|
||||
$fh = fopen($out, 'wb');
|
||||
if (!$fh) {
|
||||
fwrite(STDERR, "Cannot write $out\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$start = random_int($startFloorMs, $nowMs - 120000);
|
||||
$duration = random_int(120, 9000);
|
||||
$end = $start + ($duration * 1000);
|
||||
$payload = [
|
||||
'schema_version' => 1,
|
||||
'ingest_kind' => 'graph_session',
|
||||
'session_id' => 'fake-' . gmdate('Ymd', (int) ($start / 1000)) . '-' . substr(sha1((string) ($start . ':' . $i)), 0, 12),
|
||||
'company_id' => $companyId,
|
||||
'user_id' => $userId,
|
||||
'started_at_epoch_ms' => $start,
|
||||
'ended_at_epoch_ms' => $end,
|
||||
'duration_s' => $duration,
|
||||
'direction' => ($i % 3 === 0) ? 'receiver' : 'sender',
|
||||
'transport' => $transports[array_rand($transports)],
|
||||
'avg_kbps' => random_int(640, 12000),
|
||||
'packet_loss_pct' => round(mt_rand(0, 600) / 100, 2),
|
||||
'ntp_source' => $ntp[array_rand($ntp)],
|
||||
'install_source' => $installs[array_rand($installs)],
|
||||
'meta' => ['fake' => true, 'seed' => $i],
|
||||
];
|
||||
fwrite($fh, json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n");
|
||||
}
|
||||
fclose($fh);
|
||||
echo "Generated $count rows into $out\n";
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
$opts = getopt('', ['file::', 'url::', 'dry-run']);
|
||||
$file = $opts['file'] ?? (__DIR__ . '/../../../../tmp/fake_graph_sessions.ndjson');
|
||||
$base = rtrim($opts['url'] ?? 'https://apps.f0xx.org/app/androidcast_project/graphs', '/');
|
||||
$dryRun = isset($opts['dry-run']);
|
||||
$api = $base . '/api/graph_upload.php';
|
||||
|
||||
if (!is_readable($file)) {
|
||||
fwrite(STDERR, "File not found: $file\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
foreach ($lines as $i => $line) {
|
||||
$payload = json_decode($line, true);
|
||||
if (!is_array($payload)) {
|
||||
$fail++;
|
||||
continue;
|
||||
}
|
||||
if ($dryRun) {
|
||||
$ok++;
|
||||
continue;
|
||||
}
|
||||
$ctx = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Content-Type: application/json\r\n",
|
||||
'content' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
'ignore_errors' => true,
|
||||
'timeout' => 20,
|
||||
],
|
||||
]);
|
||||
$resp = @file_get_contents($api, false, $ctx);
|
||||
$code = 0;
|
||||
if (isset($http_response_header[0]) && preg_match('/\s(\d{3})\s/', $http_response_header[0], $m)) {
|
||||
$code = (int) $m[1];
|
||||
}
|
||||
if ($code >= 200 && $code < 300) {
|
||||
$ok++;
|
||||
} else {
|
||||
$fail++;
|
||||
fwrite(STDERR, "FAIL line " . ($i + 1) . " http=$code resp=$resp\n");
|
||||
}
|
||||
}
|
||||
|
||||
echo "Done: ok=$ok fail=$fail total=" . count($lines) . "\n";
|
||||
exit($fail > 0 ? 1 : 0);
|
||||
254
examples/crash_reporter/backend/src/GraphRepository.php
Normal file
254
examples/crash_reporter/backend/src/GraphRepository.php
Normal file
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class GraphRepository {
|
||||
public static function ensureSchema(): void {
|
||||
$pdo = Database::pdo();
|
||||
if (Database::tableExists($pdo, 'graph_sessions')) {
|
||||
return;
|
||||
}
|
||||
if (Database::isMysql()) {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE IF NOT EXISTS graph_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(96) NOT NULL,
|
||||
company_id INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
user_id INT UNSIGNED NULL,
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
ended_at_ms BIGINT NOT NULL,
|
||||
duration_s INT NOT NULL,
|
||||
direction ENUM('sender','receiver') NOT NULL DEFAULT 'sender',
|
||||
transport VARCHAR(24) NOT NULL DEFAULT 'wifi',
|
||||
avg_kbps INT NOT NULL DEFAULT 0,
|
||||
packet_loss_pct DECIMAL(6,3) NOT NULL DEFAULT 0.0,
|
||||
ntp_source VARCHAR(32) NOT NULL DEFAULT 'device',
|
||||
install_source VARCHAR(32) NOT NULL DEFAULT 'direct',
|
||||
meta_json LONGTEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_graph_session_id (session_id),
|
||||
KEY idx_graph_company_time (company_id, started_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
return;
|
||||
}
|
||||
$pdo->exec(
|
||||
"CREATE TABLE IF NOT EXISTS graph_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
company_id INTEGER NOT NULL DEFAULT 1,
|
||||
user_id INTEGER NULL,
|
||||
started_at_ms INTEGER NOT NULL,
|
||||
ended_at_ms INTEGER NOT NULL,
|
||||
duration_s INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL DEFAULT 'sender',
|
||||
transport TEXT NOT NULL DEFAULT 'wifi',
|
||||
avg_kbps INTEGER NOT NULL DEFAULT 0,
|
||||
packet_loss_pct REAL NOT NULL DEFAULT 0.0,
|
||||
ntp_source TEXT NOT NULL DEFAULT 'device',
|
||||
install_source TEXT NOT NULL DEFAULT 'direct',
|
||||
meta_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)"
|
||||
);
|
||||
}
|
||||
|
||||
public static function insertSession(array $in): void {
|
||||
self::ensureSchema();
|
||||
$pdo = Database::pdo();
|
||||
$sessionId = trim((string) ($in['session_id'] ?? ''));
|
||||
if ($sessionId === '') {
|
||||
throw new InvalidArgumentException('session_id is required');
|
||||
}
|
||||
$start = (int) ($in['started_at_epoch_ms'] ?? 0);
|
||||
$end = (int) ($in['ended_at_epoch_ms'] ?? 0);
|
||||
if ($start <= 0 || $end <= 0 || $end < $start) {
|
||||
throw new InvalidArgumentException('invalid session timing');
|
||||
}
|
||||
$dir = strtolower(trim((string) ($in['direction'] ?? 'sender')));
|
||||
if ($dir !== 'sender' && $dir !== 'receiver') {
|
||||
$dir = 'sender';
|
||||
}
|
||||
$transport = strtolower(trim((string) ($in['transport'] ?? 'wifi')));
|
||||
if ($transport === '') {
|
||||
$transport = 'wifi';
|
||||
}
|
||||
$ntp = strtolower(trim((string) ($in['ntp_source'] ?? 'device')));
|
||||
if ($ntp === '') {
|
||||
$ntp = 'device';
|
||||
}
|
||||
$install = strtolower(trim((string) ($in['install_source'] ?? 'direct')));
|
||||
if ($install === '') {
|
||||
$install = 'direct';
|
||||
}
|
||||
$duration = max(0, (int) ($in['duration_s'] ?? (int) (($end - $start) / 1000)));
|
||||
$avgKbps = max(0, (int) ($in['avg_kbps'] ?? 0));
|
||||
$loss = max(0.0, (float) ($in['packet_loss_pct'] ?? 0.0));
|
||||
$companyId = (int) ($in['company_id'] ?? Rbac::defaultCompanyId());
|
||||
if ($companyId <= 0) {
|
||||
$companyId = Rbac::defaultCompanyId();
|
||||
}
|
||||
$userId = isset($in['user_id']) ? (int) $in['user_id'] : null;
|
||||
if ($userId !== null && $userId <= 0) {
|
||||
$userId = null;
|
||||
}
|
||||
$meta = $in['meta'] ?? null;
|
||||
$metaJson = is_array($meta) ? safe_json_encode($meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) : null;
|
||||
|
||||
if (Database::isMysql()) {
|
||||
$sql = 'INSERT INTO graph_sessions
|
||||
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
ended_at_ms = VALUES(ended_at_ms),
|
||||
duration_s = VALUES(duration_s),
|
||||
avg_kbps = VALUES(avg_kbps),
|
||||
packet_loss_pct = VALUES(packet_loss_pct),
|
||||
ntp_source = VALUES(ntp_source),
|
||||
install_source = VALUES(install_source),
|
||||
meta_json = VALUES(meta_json)';
|
||||
} else {
|
||||
$sql = 'INSERT OR REPLACE INTO graph_sessions
|
||||
(session_id, company_id, user_id, started_at_ms, ended_at_ms, duration_s, direction, transport, avg_kbps, packet_loss_pct, ntp_source, install_source, meta_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
|
||||
}
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute([
|
||||
$sessionId,
|
||||
$companyId,
|
||||
$userId,
|
||||
$start,
|
||||
$end,
|
||||
$duration,
|
||||
$dir,
|
||||
$transport,
|
||||
$avgKbps,
|
||||
$loss,
|
||||
$ntp,
|
||||
$install,
|
||||
$metaJson,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function dashboardData(int $days = 14): array {
|
||||
self::ensureSchema();
|
||||
$days = max(3, min(60, $days));
|
||||
$user = Auth::user();
|
||||
$uid = (int) ($user['id'] ?? 0);
|
||||
$scopeUserId = $uid > 0 ? $uid : null;
|
||||
$activeCompanyId = Rbac::activeCompanyId($user) ?? Rbac::defaultCompanyId();
|
||||
|
||||
return [
|
||||
'window_days' => $days,
|
||||
'user' => self::buildScope($days, $activeCompanyId, $scopeUserId),
|
||||
'slug_admin' => self::buildScope($days, $activeCompanyId, null),
|
||||
'platform_admin' => self::buildScope($days, null, null),
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildScope(int $days, ?int $companyId, ?int $userId): array {
|
||||
$startMs = (int) ((time() - ($days * 86400)) * 1000);
|
||||
return [
|
||||
'sessions_per_day' => self::sessionsPerDay($startMs, $companyId, $userId),
|
||||
'crashes_per_day' => self::crashesPerDay($startMs, $companyId),
|
||||
'avg_bitrate_kbps_per_day' => self::avgBitratePerDay($startMs, $companyId, $userId),
|
||||
'ntp_sources' => self::breakdown('ntp_source', $startMs, $companyId, $userId),
|
||||
'install_sources' => self::breakdown('install_source', $startMs, $companyId, $userId),
|
||||
'transport_mix' => self::breakdown('transport', $startMs, $companyId, $userId),
|
||||
];
|
||||
}
|
||||
|
||||
private static function sessionsPerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$bucket = [];
|
||||
foreach ($rows as $row) {
|
||||
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
|
||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
||||
}
|
||||
return self::assocToSeries($bucket);
|
||||
}
|
||||
|
||||
private static function crashesPerDay(int $startMs, ?int $companyId): array {
|
||||
$where = ['received_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
$params[] = $companyId;
|
||||
}
|
||||
$sql = 'SELECT received_at_ms FROM reports WHERE ' . implode(' AND ', $where) . ' ORDER BY received_at_ms ASC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$bucket = [];
|
||||
foreach ($rows as $row) {
|
||||
$day = gmdate('Y-m-d', ((int) ($row['received_at_ms'] ?? 0)) / 1000);
|
||||
$bucket[$day] = ($bucket[$day] ?? 0) + 1;
|
||||
}
|
||||
return self::assocToSeries($bucket);
|
||||
}
|
||||
|
||||
private static function avgBitratePerDay(int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT started_at_ms, avg_kbps FROM graph_sessions WHERE ' . implode(' AND ', $where) . ' ORDER BY started_at_ms ASC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$sum = [];
|
||||
$cnt = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$day = gmdate('Y-m-d', ((int) ($row['started_at_ms'] ?? 0)) / 1000);
|
||||
$sum[$day] = ($sum[$day] ?? 0.0) + (float) ($row['avg_kbps'] ?? 0);
|
||||
$cnt[$day] = ($cnt[$day] ?? 0) + 1;
|
||||
}
|
||||
$avg = [];
|
||||
foreach ($sum as $day => $total) {
|
||||
$avg[$day] = $cnt[$day] > 0 ? round($total / $cnt[$day], 1) : 0.0;
|
||||
}
|
||||
return self::assocToSeries($avg);
|
||||
}
|
||||
|
||||
private static function breakdown(string $column, int $startMs, ?int $companyId, ?int $userId): array {
|
||||
$allowed = ['ntp_source', 'install_source', 'transport'];
|
||||
if (!in_array($column, $allowed, true)) {
|
||||
return [];
|
||||
}
|
||||
$where = ['started_at_ms >= ?'];
|
||||
$params = [$startMs];
|
||||
self::appendScope($where, $params, $companyId, $userId);
|
||||
$sql = 'SELECT ' . $column . ' AS k, COUNT(*) AS c FROM graph_sessions WHERE '
|
||||
. implode(' AND ', $where) . ' GROUP BY ' . $column . ' ORDER BY c DESC';
|
||||
$stmt = Database::pdo()->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$out[] = ['label' => (string) ($row['k'] ?? 'unknown'), 'value' => (int) ($row['c'] ?? 0)];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function appendScope(array &$where, array &$params, ?int $companyId, ?int $userId): void {
|
||||
if ($companyId !== null && $companyId > 0) {
|
||||
$where[] = 'company_id = ?';
|
||||
$params[] = $companyId;
|
||||
}
|
||||
if ($userId !== null && $userId > 0) {
|
||||
$where[] = 'user_id = ?';
|
||||
$params[] = $userId;
|
||||
}
|
||||
}
|
||||
|
||||
private static function assocToSeries(array $assoc): array {
|
||||
ksort($assoc);
|
||||
$out = [];
|
||||
foreach ($assoc as $k => $v) {
|
||||
$out[] = ['x' => $k, 'y' => $v];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ require_once __DIR__ . '/TicketAttachmentRepository.php';
|
||||
require_once __DIR__ . '/TicketCommentRepository.php';
|
||||
require_once __DIR__ . '/UserRepository.php';
|
||||
require_once __DIR__ . '/TicketRepository.php';
|
||||
require_once __DIR__ . '/GraphRepository.php';
|
||||
require_once __DIR__ . '/AnalyticsHead.php';
|
||||
|
||||
function cfg(string $key, $default = null) {
|
||||
|
||||
@@ -31,6 +31,9 @@
|
||||
<?php if (in_array($view ?? '', ['tickets', 'ticket'], true)): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/tickets.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php if (($view ?? '') === 'graphs'): ?>
|
||||
<script src="<?= h(Auth::basePath()) ?>/assets/js/graphs.js" defer></script>
|
||||
<?php endif; ?>
|
||||
<?php AnalyticsHead::render('crashes'); ?>
|
||||
</head>
|
||||
<body data-base-path="<?= h(Auth::basePath()) ?>"
|
||||
@@ -75,6 +78,15 @@
|
||||
<span class="nav-label" data-i18n="nav.tickets">Tickets</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/app/androidcast_project/graphs/"
|
||||
class="nav-link <?= ($view ?? '') === 'graphs' ? 'active' : '' ?>"
|
||||
aria-label="Graphs"
|
||||
title="Graphs">
|
||||
<span class="nav-icon nav-icon--graphs" aria-hidden="true"></span>
|
||||
<span class="nav-label">Graphs</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/app/androidcast_project/build/"
|
||||
class="nav-link"
|
||||
@@ -222,6 +234,60 @@
|
||||
</div>
|
||||
<nav class="reports-pagination" id="reports-pagination" aria-label="Reports pages"></nav>
|
||||
</div>
|
||||
<?php elseif (($view ?? '') === 'graphs'): ?>
|
||||
<div id="graphs-app" class="reports-app">
|
||||
<div class="toolbar reports-toolbar">
|
||||
<h1>AndroidCast graphs</h1>
|
||||
<div class="toolbar-actions">
|
||||
<label class="toolbar-select">
|
||||
<span>Theme</span>
|
||||
<select id="theme-select" aria-label="UI theme">
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toolbar-select">
|
||||
<span>Window</span>
|
||||
<select id="graphs-days" aria-label="Graphs window">
|
||||
<option value="7">7d</option>
|
||||
<option value="14" selected>14d</option>
|
||||
<option value="30">30d</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p id="graphs-status" class="reports-status muted" aria-live="polite">Loading…</p>
|
||||
<div class="cards cards--lift">
|
||||
<article class="card card--lift">
|
||||
<h3>User scope</h3>
|
||||
<canvas id="graph-user-sessions" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Slug-admin scope</h3>
|
||||
<canvas id="graph-slug-sessions" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Platform scope</h3>
|
||||
<canvas id="graph-platform-sessions" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Bitrate (kbps)</h3>
|
||||
<canvas id="graph-bitrate" width="560" height="180"></canvas>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>NTP source mix</h3>
|
||||
<div id="graph-ntp-breakdown" class="muted">—</div>
|
||||
</article>
|
||||
<article class="card card--lift">
|
||||
<h3>Install source mix</h3>
|
||||
<div id="graph-install-breakdown" class="muted">—</div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="muted">
|
||||
APIs: <code><?= h(Auth::basePath()) ?>/api/graphs.php</code> and
|
||||
<code><?= h(Auth::basePath()) ?>/api/graph_upload.php</code>
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -4,4 +4,4 @@ set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
python3 "$ROOT_DIR/docs/_pdf_build/build_graphvis_pivot_pdf.py"
|
||||
echo "Generated: $ROOT_DIR/tmp/GRAFANA_vs_others_graphvis_pivot.pdf"
|
||||
echo "Generated: $ROOT_DIR/docs/GRAFANA_vs_others_graphvis_pivot.pdf"
|
||||
|
||||
Reference in New Issue
Block a user